Skip to content

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 memory or network (6.7, 15.5). An Environment is a Runnable with no options, so wherever a command takes a Runnable, an Environment may 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 sync materializes 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.

txt
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 "A is defined as B".
  • | denotes alternation.
  • { X } denotes zero or more repetitions of X.
  • [ X ] denotes that X is optional.
  • ( X ) denotes grouping.
  • "..." denotes a terminal symbol (a literal string).
  • production_name denotes a nonterminal symbol (a reference to another production rule).

Lask examples:

  • A code block marked lask is a complete module: on its own, as main.lask, it passes lask check.
  • A code block marked lask fragment is 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_char is a terminal symbol representing any Unicode scalar value except a newline.
  • newline is 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.

txt
line_comment  = "//" { unicode_char } .
block_comment = "/*" { unicode_char | newline | block_comment } "*/" .

Rules:

  • Because unicode_char does 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_comment itself in the definition of block_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 both out_dir and out-dir designate 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 @param that names a parameter the declaration does not have is not an error either. It may be reported as the advisory diagnostic W-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 ​

txt
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-b is unambiguously parsed as a - 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 ​

txt
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_char is a unicode_char excluding '. 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_char is a unicode_char excluding ", \, and newlines, or an escape sequence. A newline cannot be included directly in an interpreted string; use \n instead.
  • 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 a do block 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 as 0 - 1 (unary minus is not provided).
  • env_head is 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 an env_char (whitespace, ], ,, ), }, etc.) appears.
  • A # not followed by at least one env_char is a lexical error (except for the interpolation opener #{ inside interpreted strings and command strings).
  • The character set of env_char is 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_head cannot contain whitespace or any character outside env_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, null
  • return is 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.
  • on is 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 show on as a keyword there without elaborating the module.
  • stdin is 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 (HEX is 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 value
    • Number: Integers and floating-point numbers
    • String: UTF-8 strings
    • Bool: Boolean values
    • Null: Absence of a value
    • Void: Absence of a return value
    • Environment: 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 by runnable (15.5) and its sugar #head{...} (6.7), and as the session with_container gives its body (15.5). Environment conforms to it (4.4); serialization is as for Environment.
  • Composite types
    • Array<T>: An array whose element type is T
    • Map<T>: A map with string keys and value type T
    • Record<field1: T1, field2: T2, ...>: A record with fixed fields
    • AsyncHandle<T>: A handle referring to the result T of an asynchronous execution
    • Function<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 ​

txt
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 takes T1, T2, ... in positional order and returns R.
  • AsyncHandle<T> denotes a reference to a result T of 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, or type TypeName<A, B> = Type for 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 as namespace.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. namespace must be a namespace bound by a NamespaceImport in the current module, and TypeName must be a public type alias of the module that namespace refers to (5, "Public scope and visibility"); otherwise it is an undefined reference error. QualifiedNamedType is recognized only in type-annotation position (wherever Type appears in this grammar) — it does not extend to a namespace chain (m.n.TypeName) and does not change how namespace.symbol is resolved as a value or function reference (7.2).
  • Example: type Strings = Array<String>
  • Example: given import * as m from "./lib.lask" and a public type Config = Record<...> in lib.lask, m.Config denotes 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_id in type position resolves to a type parameter when one of that name is in scope, and to a NamedType otherwise. A qualified namespace.TypeName is 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: Number and String, the types sort orders (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> admits Number, String and Number | String. It may be written through a type alias, which is how a program defines a bound of its own:

    lask
    type 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.

lask
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 as Pair<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 while Bad<Void> is E-TYPE-ILLFORMED at 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 | Null is the type of a String that 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, not String||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 type AB | C is A | B | C.
  • Deduplication: repeated members are kept once. A | A is A.
  • Collapsing: a union of one member is that member.
  • Absorption by Any: Any | T is Any, because every type already conforms to Any (4.4). Any | Null is therefore Any.
  • Ordering: members are held in a fixed order, so that String | Null and Null | String denote 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 that Null is always last. The order is total, so the canonical form of a union is unique.

Well-formedness of a union:

  • Void must 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 of AsyncHandle.
  • Every member of a union must be a data type: Number, String, Bool, Null, Environment, Any, and Array / Map / Record composed of them — the same set that cast may target (15.8). A union having a member that contains Function<...> or AsyncHandle<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 of case (6.4) — are defined for data types alone. A type such as Function<Number, Number> | Null would 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.
WrittenKeyValue
a: Stringmust be presenta String
a: String | Nullmust be presenta String or null
a?: Stringmay be absenta String when present
a?: String | Nullmay be absenta 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 — cast accepts 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 explicit null for a null one (13.1).
  • ? may be written on a field name in either form, a?: String or "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 a Map<T> with cast and asks has_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, and Record<name: String> and Record<"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_id uses 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:

  • Void may appear only in the return-value position of a function type (Function<..., Void>) and as the type argument of AsyncHandle<Void>. Its appearance as a type argument of Array or Map, 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 map be Array<Void> is E-TYPE-ILLFORMED; constructing AsyncHandle<Void> via spawn is 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 of NamedType (4.3) always terminates in a finite number of steps.

Examples:

  • Base types
    • Number
    • String
    • Environment
  • 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 | Null
    • Number | Null
    • Array<String> | Null
    • Record<ok: Bool> | Null
  • Named types
    • Strings (type Strings = Array<String>)
    • User
    • BuildConfig
    • tf.TfOutputs (qualified reference to a public type alias TfOutputs of the module bound to the namespace tf)

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 is String, a boolean literal is Bool, and the null literal is Null. An environment expression #... (6.7) is Environment.
  • An optional field is never inferred: a record literal with no expected type infers every key it gives as required ({a: 1} is Record<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 type T, the literal is Array<T>, and if heterogeneous, it is Array<Any>.
  • When the expected type is Array<T>, if every element expression conforms to T, the literal's type is Array<T>. If any element does not conform, it is a type error (conformance is invariant (4.4), but literals can promote element-wise to Any etc. via expected-type-directed checking).
  • The empty array [] follows the expected type if one exists; with no expected type it is Array<Any>.
  • For an object literal {k1: e1, ...} with no expected type, it is inferred as Record<k1: T1, ...> (Ti is the inferred type of each ei). 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 as b?: String may be omitted, and when written must hold a String -- {b: null} is a type error unless the field is b?: String | Null.
    • When the expected type is Map<T>: when every value expression conforms to T, the literal's type is Map<T>. If any value does not conform, it is a type error (keys are always treated as String. 4.4).
    • When the expected type is Any: inferred as Record<...> 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 a Map<T>, the literal is checked against that member by the rules above, so f(): 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 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 an IfExpr and of a conditional expression c ? a : b (6.2), and the arms of a CaseExpr (6.4), unchanged. Therefore f(): String | Null = if (c) { "x" } else { null } is well-typed: each branch is checked against String | Null, and both String and Null conform 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, Any is adopted.
  • In a function call f(a1, ..., an), f must resolve to Function<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 is R.
  • 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 NamedType is expanded into its TypeAliasDecl before 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 = 1 is inferred as n: Number.
  • ok = true is inferred as ok: Bool.
  • names = ["a", "b"] is inferred as names: Array<String>.
  • mixed = [1, "a"] is inferred as mixed: Array<Any>.
  • empty = [] is inferred as empty: Array<Any> when there is no expected type.
  • user = {name: "alice", age: 20} is inferred as user: Record<name: String, age: Number>.
  • envMap: Map<String> = {"APP_ENV": "prod", "REGION": "ap-northeast-1"} is typed as Map<String> due to the expected type Map<String>.
  • hdrs = {"X-Api-Key": "secret"} is inferred as hdrs: 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 to Number.
  • For add(x: Number, y: Number) = x + y, the call add(1, 2) is inferred to have result type Number.
  • identity(x) = x is inferred as identity: Function<Any, Any> because the parameter has no annotation and no default value.
  • For inc(--x = 0) = x + 1, the keyword parameter x: Number is inferred from the default value 0, and inc is inferred as inc: Function<Number> (no positional parameters). The calls inc() and inc(x = 5) are both legal, with result type Number.
  • Given type Strings = Array<String>, xs: Strings = ["a"] is treated as equivalent to xs: 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: T conforms to T. Type identity is determined by structural identity after expanding NamedType (4.3) and reducing unions to canonical form (4.2).
  • Top type: any T conforms to Any. Any is the only top type.
  • Union introduction: a type T that is not a union conforms to U1 | ... | Un when T conforms to some Ui.
  • Union elimination: T1 | ... | Tm conforms to U when every Ti conforms to U.
  • Environments: Environment conforms to Runnable. An environment is a runnable with no run options (10.1), so it may be placed wherever a command execution needs a runnable. Runnable does not conform to Environment.
  • 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 to Array<Any> (element types must be identical). The same applies to Map and AsyncHandle.
  • The union rules are likewise not lifted: Array<String> does not conform to Array<String | Null>, and Record<a: String> does not conform to Record<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 in xs: Array<String | Null> = ["a", null].
  • Record has 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 to Record<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.

  • Any is the only top type that accepts values of all types (any type conforms to Any).
  • An expression of type Any may be placed only in positions requiring Any. Moving from Any to 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 of case (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 case applies to a scrutinee of type Any exactly as it does to a union, with one difference that follows from Any having no members: an arm selected by the type head M narrows the scrutinee to M, and the else arm leaves it Any, because a type cannot be subtracted from Any and nothing is learned by a test that failed (6.4).
  • Null is a value type representing the absence of a value and can be evaluated as an expression.
  • Void is a type representing a computation result that returns no value, and must not be used as a value.
  • Null and Void are not identified with each other. Placing Void in a position requiring Null, or vice versa, is a type mismatch.
  • Array<T> denotes an array whose elements are uniformly T.
  • 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 to String.
  • 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, and Record<...> 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 of cast (15.8) or by case type dispatch (6.4).
  • AsyncHandle<T> is a runtime handle managing the lifecycle of an asynchronous computation, and is resolved to T by await.
  • 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 to Any occurs only in positions requiring a top-level Any).
  • 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 NamedType is treated as equivalent to the type obtained after expanding the corresponding TypeAliasDecl.

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_id in a type annotation that is not a type parameter in scope is a NamedType, 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 a fail (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, is Function<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 type String | Null binds T to String by the first route and an argument of type String by 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 | Null against an expected String | Null binds T to String). 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>> = map can be instantiated via the expected-type annotation, but m = map without 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 instantiate reverse at — 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 B when it conforms to B. 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 a case value head (6.4) need comparable, and interpolation (6.6) needs stringifiable. A violation of these is E-TYPE-BOUND as 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.
  • orderable entails comparable and stringifiable, as well as itself. comparable and stringifiable entail only themselves.
  • A type bound B entails a named bound that B itself satisfies, except that Any entails nothing: every type conforms to Any. So <T: Number | String> entails comparable and stringifiable, but not orderable, 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 bound B, to whatever B conforms 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 of cast (15.8) or a legal case type head (6.4), being neither a union nor Any.
  • A value of an unbounded type parameter's type can be bound, passed, returned, placed in an Array / Map / Record, and serialized through Any — 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 where Number is required, but x + 1 is a Number and not a T.
  • 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, Null
    • Array<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, Runnable
    • AsyncHandle<T>
    • Function<T1, T2, ..., R>
  • Function values are converted to reference metadata (FunctionRef; 13.2) in execution records and external output.
  • Likewise, Void, Environment and Runnable are 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.

txt
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_end may 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.
  • as and from are not continuation tokens (6.5). An import declaration must be written on one line, except inside the braces of NamedImports (which may span multiple lines by the open-bracket continuation rule). The closing } and the from clause 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 with import, type, or an identifier, this case results in a syntax error.
  • The optional !! marker on a ValueDecl name declares a secret binding (6.10); it does not affect parsing of the rest of the declaration.
  • A < following the name of a FunctionDecl or a TypeAliasDecl begins 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. A ValueDecl takes no type parameters, having no place to instantiate them.
  • Type parameters are not part of a declaration's name. first<T> declares the symbol first, which is what an import, an export and a duplicate-definition check see.
  • export and internal are reserved words (3.3), so a leading marker is unambiguous and needs no lookahead: Visibility appears only at the start of a top-level declaration, and for export a following { begins an ExportDecl instead. Neither word can be a declaration name, and like every reserved word neither is a lower_id in any other position (4.2 covers what this means for field names).
  • export is not a continuation token. An ExportDecl must be written on one line, except inside the braces of NamedImports; the closing } and the from clause must be placed on the same line.
  • schema is a contextual keyword, like on: 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.
  • CommandWords is 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 the on or from that follows it must be placed on the same line. After export command, the word after the closing brace decides the form: on begins a command declaration, from a 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_lit containing 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, 7z and g++ are valid, while my prog, a=b, /usr/bin/x and foo* are not.
  • The environment is any expression of type Runnable — an Environment, 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; cmd can 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.
  • #local is 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 cmd evaluates it (11.8), is a container environment that names no platform. 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 by lask 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 #local is not probed: the host's programs may be installed by the very tasks that run them.
  • Before run or eval evaluates anything, and before cmd runs its command, a probed word recorded as missing from its image is an external I/O error (E-IO-COMMAND-MISSING, exit code 3) when the invocation can reach a command execution expression whose environment dispatch selected through that word (10.9); for run and eval, 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; internal keeps 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.
lask
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 #local

Command 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 type Map<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:

    lask
    type 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 to CommandSchema (4.4).

  • What the types cannot say is checked where the schema is written, and a violation is E-TYPE-COMMAND-SCHEMA: style is "gnu" or "single-dash"; value is "none", "required" or "optional"; an option has at least one name, and each name begins with - — or, for an option with prefix, 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; values lists at least one value and is not given for an option whose value is "none"; an option with prefix takes 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:
    1. 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.
    2. An option with stops ends the check once it and its value are read: python -m pytest -k smoke checks -m and its value, and leaves -k to pytest.
    3. 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.
    4. -- ends the check.
    5. A word beginning with -, other than - alone, is an option. In the gnu style --name and --name=value name a long option — with abbreviations, also any prefix of exactly one long name, a prefix of several being ambiguous — and -abc the single-letter options -a, -b, -c, where the first that takes a value takes the rest of the word as it. In the single-dash style -name and -name=value name an option, and --name names the option of that name if there is one and is otherwise read as -name: a program such as java has both -cp and --class-path. In either style, a word that names no option but begins with the name of an option with prefix is 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.
    6. 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, with stop_at_first_positional, or when options do not intermix, it ends the check.
  • 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 =value whose value is "none";
    • an option whose value is "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 is closed, and otherwise the advisory W-COMMAND-OPTION (14.2), since a spec that is not closed does not claim to be complete. The other findings are E-TYPE-COMMAND-OPTION whatever the spec says.
lask
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, as ExportDecl does 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.
  • ImportPath follows 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.
lask
// 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 import declarations.

Import path resolution:

  • An ImportPath starting 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 .lask file, the dependency name is the module path (e.g. import { send } from "notify").
  • For a dependency fetched as a source tree (git, or an archive url), the dependency name resolves to main.lask at 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 Declaration or TypeAliasDecl declared at the top level is a public symbol of that module, unless it carries the internal marker.
  • 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 internal is not a public symbol. It is therefore unreachable by external import, by local import from another file of the same tree, by ExportDecl, 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 symbol a under the name b (renaming import). The identifiers before and after renaming must be of the same kind (lower_id with lower_id, upper_id with upper_id).
  • A namespace import import * as m from "path" refers to the target module through the namespace m. Public symbols are not brought in implicitly and are referenced in the form m.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 as m.symbol in 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.TypeName is well-formed only when TypeName is a public type alias (not internal) of the module m refers 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. The ImportSpecifier rules, including as renaming, 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.f produces 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.
  • ImportPath may 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, ns is bound as the namespace import import * 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.ns names the namespace and m.ns.symbol a public symbol of the module ns refers to, and a call m.ns.f(...) keeps f's parameter list as a call through m.f does. A named import import { ns } from "path" binds ns as a namespace of the importing module. The same holds through any number of namespaces.
  • A namespace is not a value: m.ns written 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 ns is 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. ExportDecl edges 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:
json
{
  "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: dependencies and confirm (below). Any other key is an error, so that a misspelt key is never silently ignored.
  • The top-level dependencies map associates a dependency name (a string conforming to lower_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) or url (an archive or a single .lask file). A branch must not be given as rev.
  • git, rev and url must 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. A url must be an https://, http:// or file:// URL, and a redirect followed while fetching it must not move from https to http. A violation is a static error (E-MODULE-UNRESOLVED), in the project file and in lask 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 its confirm map.

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.

  • confirm maps 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. Without when, confirmation is always required.
  • phrase (optional) is what has to be typed. #{name} interpolates the value of the parameter name. By default it is the value of the first parameter that matched when, and the function's name when there is no when.
  • An entry takes no other key. Only the root project's file is read. A dependency's own confirm does not apply to whoever imports it.
  • check, run, eval and envs check every entry against the program, whether or not anything calls the function. The function must be a public function of the entry module, every parameter when names and every parameter phrase interpolates must be a parameter of it, and every value in when must 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 in lask.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:
json
{
  "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 modules are dependency paths: a direct dependency is its name; a transitive dependency is parent>child. Duplicate fetches of the same content are permitted and each occurrence is recorded (no version unification is performed).
  • rev in the lock must be a full 40-hexadecimal-digit commit SHA. requested preserves the reference that was resolved.
  • hash (a content hash of the fetched source) is required for every entry, in the form sha256- followed by 64 lower-case hexadecimal digits. The same entry must always yield identical source code.
  • A lock entry whose hash or rev is not of its form is a static error (E-MODULE-LOCK-STALE).
  • images records 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.
  • commands records, for each command word declared on the image that sync probes (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 no commands. Any other value is a static error (E-MODULE-LOCK-STALE).

Consistency:

  • check, run, eval, and envs require 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 hash by the CLI (lask sync; 11.7). A verification failure is E-MODULE-HASH-MISMATCH.
  • The cache is located at .lask/deps under the base directory for module resolution, unless LASK_CACHE_DIR names 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 wherever LASK_CACHE_DIR puts 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 against hash when it uses it, at most once per resolution. An entry that is itself a symbolic link does not verify. sync replaces 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 hash covers. 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 is E-MODULE-UNRESOLVED. What a dependency loads is thereby what its hash covers.
  • If a tag recorded as requested later resolves to a different commit, sync reports E-MODULE-REV-MOVED. This is distinguished from E-MODULE-HASH-MISMATCH, which denotes content differing from the pinned hash for an unchanged reference.
  • check, run, eval, and envs must 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.json that a fetched dependency carries but that is not a valid project file is a failure of that dependency in sync and deps add, reported with the dependency it belongs to; its dependencies are not taken to be none.

Examples:

lask
// module: lib/math.lask
add(x: Number, y: Number): Number = x + y
lask
// module: lib/types.lask
type Strings = Array<String>
joinWithComma(xs: Strings): String = join(xs, ",")
lask
// 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.

lask
// module: lib/deploy.lask
export rollout(--target: String = "staging"): String = $[#alpine:3.20] echo "#{target}"
lask
// 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.

txt
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 ​

txt
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 = expression in 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 type Any.
  • 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 = x where x is a preceding parameter of type T (the evaluation environment of a default includes the parameters bound before it, 8.3), --xs: Array<T> = [], --m: Map<T> = {}, --y: T | Null = null and 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 name for 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.f as 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 ...name may 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 is Array<Any>. Within the function body it is referenced as a value of type Array<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>): Number is Function<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:

lask
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:

lask
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) = body is equivalent to name = \(p1, p2, ..., pn) -> body.
  • The parameter specification of FunctionDecl follows the same rules as the FunctionParameterList of LambdaExpr.
  • 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:

lask
f(x) = x + 1
g = \(x) -> x + 1

The f and g above declare essentially the same function.

6.2 Operators ​

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

  1. Unary operator !
  2. Multiplicative operators * /
  3. Additive operators + -
  4. Comparison operators == != < <= > >=
  5. Logical operators && ||
  6. Pipe and composition operators |> <| >> <<
  7. 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 : b is syntactic sugar for if (c) { a } else { b } (6.4). It is the form of IfExpr whose branches are single expressions, and it is typed and evaluated exactly as that IfExpr is: c must be Bool, the two branches must have the same type T, 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 : 1 tests is_empty(xs). Each branch is a whole Expression, so c ? a || b : xs |> f needs no parentheses.
  • It associates to the right: a ? x : b ? y : z is a ? x : (b ? y : z), and a conditional may stand in the middle branch without parentheses, as in a ? b ? x : y : z. A multi-way choice with more than two conditions is usually clearer as the condition form of case (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 in c ? $ echo a : $ echo b the then branch swallows : $ echo b, while the three-line form below does not.
lask
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.

  • !e denotes Boolean negation.
  • e1 * e2, e1 / e2, e1 + e2, e1 - e2 denote arithmetic operations.
  • e1 == e2, e1 != e2 denote value comparison.
  • e1 < e2, e1 <= e2, e1 > e2, e1 >= e2 denote ordering comparison.
  • e1 && e2 does 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 || e2 does 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 |> f is syntactic sugar for f(e1).
  • f <| e1 is syntactic sugar for f(e1).
  • f >> g denotes function composition equivalent to \(x) -> g(f(x)).
  • f << g denotes function composition equivalent to \(x) -> f(g(x)).

The types of the operators are as follows.

  • ! is typed as Function<Bool, Bool>.
  • *, /, +, - are typed as Function<Number, Number, Number>.
  • <, <=, >, >= are typed as Function<Number, Number, Bool>.
  • &&, || are typed as Function<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 is Bool. 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 is E-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 to Any, and Any is 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 entails comparable (4.4). This is the named bound comparable (4.2).
  • Therefore x == null is well-typed when x: String | Null, since Null conforms to String | Null and that union is comparable. It compares values and does not by itself narrow the type of x; narrowing is a property of case (6.4).
  • Applying == / != to types containing Function, AsyncHandle, Void, or Any is a static error. To compare values of Any, first move to a concrete type with cast (15.8), or narrow with case (6.4), and then compare.
  • Equality of structured values (Array / Map / Record) is determined by recursive structural comparison of elements, keys, and fields. Equality of Environment follows 8.8.
  • e1 |> f is well-typed when e1: T and f: Function<T, R> hold, and the type of the whole expression is R.
  • f <| e1 is well-typed when e1: T and f: Function<T, R> hold, and the type of the whole expression is R.
  • f >> g is well-typed when f: Function<T, U> and g: Function<U, R> hold, and the type of the whole expression is Function<T, R>.
  • f << g is well-typed when g: Function<T, U> and f: Function<U, R> hold, and the type of the whole expression is Function<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 ​

txt
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:

  • spawn is a core function that asynchronously launches the zero-argument function received as its argument and returns a result handle.
  • await is a core function that receives a result handle and joins to the completion value.

Syntax rules:

  • async e is syntactic sugar for spawn(\() -> e). Because async needs to delay the evaluation of its operand e, it is provided as syntactic sugar rather than as a function.
  • await e is a parenthesis-omitting form applying the core function await to the operand e. Writing await (e) is also parsed by the same rule as an AwaitExpr with a parenthesized operand, and the meaning is the same.
  • The operand of AsyncExpr / AwaitExpr is a UnaryExpr. Therefore await h |> f is interpreted as (await h) |> f. To mean await (h |> f), make it explicit with parentheses.
  • await is a reserved word and can appear only in the form of an AwaitExpr. 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 of async e is AsyncHandle<T>.
  • When f: Function<T>, the type of spawn(f) is AsyncHandle<T>.
  • When h: AsyncHandle<T>, the type of await h is T.
  • Passing anything other than AsyncHandle<...> to await is a type error.

Evaluation rules:

  • async e starts evaluation at the point of the call and immediately returns a handle value AsyncHandle<T> to the caller.
  • await h suspends the current evaluation until h completes, and returns the result value after completion.
  • Applying await h multiple 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 of await h.
  • A handle is consumed when it is passed to await, all, or race, or when its computation is cancelled because a timeout body 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 advisory W-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 type AsyncHandle<T> or Array<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-UNAWAITED covers 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 ​

txt
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 of e by equality (==, 6.2), or a type, tested against the runtime type of e (type dispatch). The two kinds of head may be mixed in one case.
  • Without a scrutinee, case { ... }: each arm head is a condition of type Bool, tested in order. This form takes the place of a chained else if, which the grammar does not provide, because the else of an IfExpr always takes a Block. 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 -> ... } compares x with the value of y; it never binds y. 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_id is 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 an upper_id can never begin an expression in this language: there are no data constructors, and a namespace is a lower_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 ,.
  • null and Null are both legal heads of the same test: the value head null compares by equality, the type head Null dispatches on the type, and because Null has exactly one value the two select the same arm and narrow identically (below). Prefer Null when the other arms of the same case are type heads, and null otherwise.
  • 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 is true (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 one case, never within one arm.
  • The body of an arm is an Expression, not a Block, as in LambdaExpr (6.1) — which is what an arm becomes after normalization. A sequence of statements is written as an explicit do { ... } (6.5), and a { directly after -> is an object literal.
  • Exactly one else arm is required, and it must be the last arm. A case with no arms, with no else arm, with more than one, or with an else arm 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 else is a continuation and not a terminator (6.5), so no arm terminator is required before the else arm. An IfExpr in an arm body may likewise place its else block on the following line: an else followed by a Block continues that IfExpr, and an else followed by -> begins the else arm.

Definitions as functions:

  • choose is 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).
    • map applies the body function to each element and returns the results as an array in the same order.
    • filter keeps, in the same order, only the elements for which the predicate returns true.
    • reduce aggregates a collection into a single value with an initial value and a folding function.
    • for_each applies the body function to each element, discards the results, and returns Void (iteration for side effects).

Desugaring rules:

  • if (c) { ... } else { ... } is syntactic sugar for choose(c, \() -> do { ... }, \() -> do { ... }).
  • if (c) { ... } without else is syntactic sugar for if (c) { ... } else { null }, except when the type of its block is Void, where it is if (c) { ... } else {}.
  • for (x: xs) { ... } is syntactic sugar for map(xs, \(x) -> do { ... }). However, when the type of the body block is Void (including an empty block), it is syntactic sugar for for_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 Void or use for_each directly.
  • case (e) { p -> b; R } is syntactic sugar for do { s = e; choose(s == p, \() -> b, \() -> C) }, where s is a fresh name that no source program can write (it therefore captures nothing and shadows nothing) and C is the expansion of the remaining arms R. 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 s against the head type rather than an equality: case (e) { M -> b; R } is do { s = e; choose(<s is M>, \() -> b, \() -> C) }. The test <s is M> is the runtime type check of cast at target M (15.8) used as a predicate, without its conversion and without its failure.
  • When the scrutinee is a plain local name x and 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> is cast(s) at the narrowed type when narrowing leaves exactly one member, and s itself 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 -> b produces the condition s == p1 || s == p2 in the scrutinee form, and p1 || p2 in the condition form.
  • case { c -> b; R } (the condition form) is syntactic sugar for choose(c, \() -> b, \() -> C). It introduces no binding.
  • The final arm else -> bz expands to bz itself, as the false branch of the innermost choose.
  • case introduces no core function of its own, and no new evaluation rule: every form of it is a nested choose.

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:

  • choose is typed as Function<Bool, Function<T>, Function<T>, T>.
  • map is typed as Function<Array<T>, Function<T, U>, Array<U>>.
  • filter is typed as Function<Array<T>, Function<T, Bool>, Array<T>>.
  • reduce is typed as Function<Array<T>, U, Function<U, T, U>, U>.
  • for_each is typed as Function<Array<T>, Function<T, U>, Void> (the return type U of the body is arbitrary and the result is discarded. The type variable U is used rather than Any because conformance of function types is limited to identity (4.4), in order to accept bodies of arbitrary return type through instantiation).
  • An IfExpr is well-typed only when the condition expression is Bool and the types of the two branch blocks are the same T, and the type of the whole expression is T.
  • An if without else is well-typed when the condition expression is Bool and its block has a type T. Its type is T | Null, reduced to canonical form (4.2): when the condition is false its value is null. A T that already admits Null, or is Any, is unchanged by this. When T is Void — a block run for its effect — the type is Void, as with an empty else.
  • With an expected type E (4.3), the block is checked against E, and Null must also conform to E, unless E is Void. So f(c: Bool): String | Null = if (c) { "x" } is well-typed and f(c: Bool): String = if (c) { "x" } is a type error (E-TYPE-MISMATCH), whose remedy is an else.
  • Without an expected type, a block whose type is given only by its context, such as fail(e) (15.7), is checked as Void, the type an empty else would give it. if (bad) { fail(e) } therefore stands as a statement that stops the function when bad holds.
  • T | Null must be a type, so T must be a data type (4.2) when the value is used; a block of a function type or of AsyncHandle<T> is E-TYPE-ILLFORMED, and wants an else. A statement that is not the last of its block discards its value (6.5), and there the block may have any type.
  • A CaseExpr with a scrutinee is well-typed only when every value head's type conforms (4.4) to the type S of the scrutinee, and, when the expression has at least one value head, S is 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 type Any is therefore a type error in a case having any value head, because Any is not comparable: dispatch on its type with type heads (below), or move to a concrete type with cast (15.8) first.
  • A type head is well-typed only when S is a union and the head is one of its members, or when S is Any and the head is a type cast may target (a data type; 4.2, 15.8). E-TYPE-MISMATCH for a head that is not a member of the union, and E-TYPE-ILLFORMED for 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. Any admits 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 case all of whose heads are type heads does not require S to be comparable, since it performs no equality test. This is what makes case usable on an Any: Any is not comparable (6.2), so a value head on an Any scrutinee stays a type error while a type head is well-typed.
  • A CaseExpr without a scrutinee is well-typed only when every arm head has type Bool.
  • A CaseExpr is well-typed only when the bodies of all arms, the else arm included, have the same type T, and the type of the whole expression is T.
  • Two heads of the same CaseExpr that 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 same CaseExpr denoting 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 is Array<T> and the type of the body block is R (R other than Void), has the type Array<R> as the whole expression.
  • When the type of the body block is Void, the ForExpr is normalized to for_each and the type of the whole expression is Void.

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 type M1 | ... | Mk, reduced to canonical form (4.2). With the usual single head M, that is M.
  • In the body of an arm selected by a value head of type H, the name has type H when H is a member of the union, and its declared type otherwise. Equality with a value of type H can only succeed for a value of type H. With several value heads it is the union of their types, by the same rule.
  • In the body of the else arm, the name has the declared union with every member matched by a preceding arm subtracted: a type head M subtracts M, and the value head null subtracts Null, because Null has exactly one value and matching it exhausts the member. Any other value head subtracts nothing — "a" does not exhaust String. Subtracting every member but one leaves that member, and subtracting nothing leaves the declared type. When the preceding arms subtract every member, the else arm 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 Any rather than a union, the arm rules above apply unchanged — an arm with the type heads M1, ..., Mk narrows the name to M1 | ... | Mk — and the else arm leaves the name Any. A type cannot be subtracted from Any, since Any has no members to remove, and a failed test tells a program nothing about what the value is.
  • Narrowing is a property of case alone. if (x == null) { ... } else { ... } does not narrow x in 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.

lask
// 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 IfExpr first evaluates only the condition expression, and evaluates only the then block if true, or only the else block if false (following choose of 8.5). Without an else block, a false condition gives null, or nothing when the block is Void.
  • A ForExpr evaluates 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 (following map of 8.5).
  • A CaseExpr with 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 the else arm 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<...> and Map<T>, two type heads of one case can both match the same runtime value. Order decides, as it does for value heads: the first arm that selects wins.
  • A CaseExpr without a scrutinee evaluates the conditions in order and stops at the first that is true, evaluating only that arm's body. When none is true, the body of the else arm 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 Block follows the same statement rules as a do block (6.5), and the value of the block is the value of its final statement. The value of an empty block with no statements is Void (6.5).

Examples:

lask
// 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 ​

txt
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 BindStmt takes an optional type annotation, in the same shape as a ValueDecl (5): the !! marker attaches to the name and the annotation follows it, as in a!!: 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 Void is not among the positions those rules allow: a binding cannot be annotated Void (E-TYPE-ILLFORMED), because a Void value 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 of if / case / for are expressions (the IfExpr / CaseExpr / ForExpr of 6.4), so no dedicated statement forms exist. They appear as an ExprStmt or as the right-hand side of a BindStmt.

Statement termination rules:

  • A statement inside a do block 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.; the binary_op of 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 an IfExpr). Inside a case block the same rule is what lets an else arm begin a line (6.4)
    • A newline where the leading token of the next line is catch or finally (continuation after the closing } of a block of a TryExpr)
  • as and from are neither continuation tokens nor leading tokens of continuation. A line-leading as or from does not continue the preceding statement or declaration (see the constraints on import declarations 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.
  • do gives only sequential evaluation order and introduces no execution model of its own.
  • Control is always lowered to functions (choose and collection functions such as map), avoiding duplication of evaluation rules.

Desugaring rules:

  • The desugaring rules for if (...) { ... } else { ... } / case (...) { ... } / for (...) { ... } follow 6.4.
  • do { e } is equivalent to e.
  • do { v = e1; s2; ...; sn; } is normalized to an expression that first evaluates e1, binds it to v, and then evaluates s2 ... sn.
  • do { e1; s2; ...; sn; } is normalized to an expression that discards the value of e1 and evaluates s2 ... 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 do become visible in order and can be referenced by subsequent statements.

No early return:

  • Lask has no return statement. 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. Writing return is 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), or case (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 when bad does not hold.

Typing rules:

  • A BindStmt is well-typed if its right-hand Expression is well-typed.
  • An ExprStmt is well-typed at any type T, but contextually its value is not passed to the next statement.
  • The type of the whole do is the type of the last statement (the trailing ExprStmt or an expression equivalent to it). If it has no statements, it is Void.
  • Empty blocks (do {}, and including empty Blocks of the IfExpr / ForExpr of 6.4) are permitted. The type of an empty block is Void, and its evaluation result is Void.
  • Even when the right-hand side of a BindStmt has type Void, the binding itself is possible, but the bound name cannot be referenced as a value (4.4).
  • The typing rules for IfExpr / CaseExpr / ForExpr follow 6.4 (the same when they appear as the right-hand side of a BindStmt or as the final statement).

Evaluation rules:

  • Statements inside do are always evaluated from top to bottom.
  • If evaluation of the right-hand side of a BindStmt fails, the whole do fails at that point and subsequent statements are not evaluated.
  • The evaluation rules for IfExpr / CaseExpr / ForExpr follow 6.4.

Scoping rules:

  • An identifier introduced by a BindStmt inside a do block is in effect within the same block after that statement.
  • Bindings inside a block of if / for and inside the body of a case arm (6.4) do not leak outside it. The name the normalization of a case scrutinee binds is fresh and cannot be referenced by any expression written in the source (6.4).
  • The iteration variable x of for (x: xs) is in effect only inside the body block.

Examples:

lask
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 ​

txt
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:

  • run is 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; an Environment conforms to Runnable (4.4). The execution environment is a required positional parameter: there is no default execution environment (10.1).
  • run succeeds 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. CommandResult must not be overridden by user-defined type aliases or value declarations.
  • code is the exit code of the command, and stdout and stderr are the contents of the respective streams. The construction rules follow 8.7.

Desugaring rules:

  • $*[env] cmd is syntactic sugar for run(env, "cmd").

  • $[env] cmd and $1[env] cmd are syntactic sugar for the following expression (r is a fresh identifier that does not collide with others).

    lask
    do {
      r = run(env, "cmd")
      if (r.code == 0) { r.stdout } else { fail({code: r.code, message: r.stderr}) }
    }
  • $2[env] cmd is syntactic sugar for the expression above with its success branch replaced by r.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, and choose), 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 a shell_string are 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 |> trim on the line after $[#local] git --version is interpreted as run(#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 run directly.

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_quote writes it (15.5): $ cp #{src} dist/ copies the file src names, 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'.
  • 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 in flags as 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 local environment on Windows is cmd.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 with shell_quote where 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 definition f() or an array assignment a=(...);
    • a hole inside a comment, which may hold a newline that ends it.
  • 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 a case command and is read only after case, 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=1 and > out.txt are well formed. A trailing ; or & ends a list and is well formed.
  • The check is not made where the environment may be the local environment of a Windows host, whose shell is cmd.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 "unterminated and $[#local] | grep x are E-TYPE-COMMAND-SYNTAX. $[#local] for f in *.go; do gofmt -l $f; done, $[#local] echo #!{flags} && and $[#local] echo hi # it's are not, because reading stops at for, at the raw hole, and at the comment.

Environment specification rules:

  • Environment is a basic type defined in 4.1, and the responsibilities of execution environments are defined in Chapter 10.
  • env is an expression of type Runnable: an Environment, 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 env fails, 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 test and $ go test are 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:

  • run is typed as Function<Runnable, String, CommandResult> (7.5).
  • As a result of the desugaring, the expression type of $ ..., $1 ..., and $2 ... is String, and the expression type of $* ... is CommandResult.
  • If env does not conform to Runnable, 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 entails stringifiable (4.4). This is the named bound stringifiable (4.2). Every other type, Null included, is not stringifiable, and to_string (15.3) accepts exactly the same set. String | Null is therefore not stringifiable: a value that may be absent cannot be interpolated into a command, and must be resolved by case (6.4) or cast (15.8) first. Any is 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 env is evaluated.
  • $* ... returns a CommandResult after the command completes, regardless of the exit code.
  • $ ... ($1 ...) and $2 ... return the contents of the selected stream when the exit code is 0. When it is nonzero, the fail of the expanded form causes a failure, and an Error value (code = the command's exit code, message = the contents of standard error output) can be caught by try/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:

lask
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 ​

txt
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 (the local profile 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_head is the lexical token defined in 3.3.
  • The opening ( of EnvArgList must be placed immediately after env_head, and the opening { of RunOptions immediately after env_head or after the closing ) of EnvArgList, 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).
  • EnvArgList takes the image options of the head's form (10.2): platform for either form, and context and build_args for 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).
  • RunOptions takes the run options of 10.2, by the same names as the keyword parameters of runnable. 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 a lower_id; the braces are not an object literal and build no value of their own.
  • #local with either list, even an empty one, is a static error (E-TYPE-ENV-CONSTRUCT).
  • A Docker image whose reference is local is written with its namespace, #library/local.
  • Environment profile names such as local / docker are not reserved words. In positions without #, they can be used as ordinary identifiers. #docker is the registry reference to the image docker.
  • Because env_char includes ., an accessor . cannot follow immediately after env_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 for runnable(#head(image options), k1 = e1, ..., kn = en) (15.5). It refers to the built-in runnable whatever the name runnable is 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 EnvExpr without RunOptions is of type Environment; one with RunOptions is of type Runnable.
  • 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:

lask
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 -a

6.8 Accessor Expressions ​

txt
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 leading m resolves 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.f is well-typed only when the type of e is Record<..., f: T, ...> (a record type having the field f), and the type of the expression is T. Where the field is optional (f?: T, 4.2), the type of the expression is T | 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 than Record (including Map, Any, and a union), it is a static error (E-TYPE-ACCESS). A value of type Any or of a union type is referenced after moving to a concrete type, with cast (15.8) or by narrowing with case (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 to lower_id (4.2) are referenced with a string-literal index (described below).
  • Value retrieval from a Map uses [...] or get (15.4). Because the field set of a Record is 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 when e: Array<T> and i: Number, and the type of the expression is T. Indexing is zero-based.
  • e[k] is well-typed when e: Map<T> and k: String, and the type of the expression is T.
  • e[k] is also well-typed when the type of e is Record<...> and k is a string literal containing no interpolation whose string matches a field name; the type of the expression is that field's type. e.f and e["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; use Map for 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 as get(m, k).

Examples:

lask
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 ​

txt
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. Error must not be overridden by user-defined type aliases or value declarations.
  • code denotes an exit code and message a human-readable message. The mapping from failures to Error values (including the carrying over of a command's exit code and standard error) is defined in 8.10.

Definitions as functions:

  • recover is a core function that receives a body function and a handler function, catches failures of the body, and joins to the handler.
  • fail is a core function that receives an Error value and raises that failure. It is used for re-raising within a handler and for raising user-defined errors.
  • error is 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) H is syntactic sugar for recover(\() -> do { B }, \(e) -> do { H }).
  • try B finally F is syntactic sugar for do { v = recover(\() -> do { B }, \(e) -> do { F; fail(e) }); F; v } (v is a fresh identifier that does not collide with others). F is evaluated exactly once on both the success and the failure path.
  • try B catch (e) H finally F is equivalent to try (try B catch (e) H) finally F (catch is applied first, and finally is applied to the whole).

Typing rules:

  • recover is typed as Function<Function<T>, Function<Error, T>, T>.
  • fail is typed as Function<Error, T>. The return type T is instantiated from the context's expected type (4.4).
  • A TryExpr with catch is well-typed only when the types of the body block and the catch block are the same T, and the type of the whole expression is T.
  • The e of catch (e) is in effect only inside the catch block, with type Error.
  • The type of the finally block is arbitrary, and its value is discarded. The expression type of try B finally F is 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 catch block or a finally block is not caught by the same TryExpr and propagates outward.
  • When TryExprs are nested, the innermost TryExpr catches first.

Examples:

lask
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 ​

txt
(* !! 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 !!e is not a double negation; write ! !e for that.

  • !! carries no meaning in the static type system. a!!: String gives a the type String, exactly as a: String would; 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 a ValueDecl or BindStmt, 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 function mark_secret (15.9):
    lask
    a!!: 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 through mark_secret:
    lask
    f(x!!: String) = body
    (* is equivalent to *)
    f(x: String) = do { x = mark_secret(x); body }
    The same transform applies to a !!-marked keyword parameter, after default-value completion (8.3).
  • mark_secret is 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 is String or String | 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 to null and say "not given" the way any other optional parameter does, and lets a credential be read with find_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's stdout / 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:

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

  1. Module dependency resolution (including cycle detection)
  2. Top-level symbol table construction
  3. Name resolution
  4. Type annotation consistency and type inference
  5. 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.

  1. Innermost local bindings (lambda parameters, do bindings, for iteration variables)
  2. Top-level declarations of the current module
  3. Symbols brought in by named imports (under their renamed names)
  4. namespace.symbol references of namespace imports
  5. 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) and stdin (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 BindStmt in a do is valid within the same block for the statements that follow the declaring statement.
  • Bindings inside if / for / try blocks, and inside the body of a case arm, do not leak outward.
  • The name that the normalization of a case scrutinee (6.4) binds is fresh: it is visible to no expression written in the source, and shadows nothing.
  • When a case narrows 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 x in for (x: xs) is valid only within the iteration body.
  • The e in catch (e) is valid only within the catch block.

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 NamedType is expanded to its TypeAliasDecl before checking, substituting its type arguments for the alias's parameters where it has them (4.2).
  • An upper_id that 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.
  • Any is broadly accepted on the receiving side (any type conforms to Any), but the transition from Any to a concrete type can only be performed by a runtime type check: cast (15.8), or the type dispatch of case (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 f statically resolves to a function declaration or lambda expression, arguments are bound to parameters by the following procedure.
    1. Positional arguments are bound to the positional parameters in declaration order, from the front. If there are fewer than m positional arguments, it is E-TYPE-ARITY.
    2. Positional arguments beyond m are collected in written order into a single array and bound if a variadic parameter exists (variadic collection); otherwise it is E-TYPE-ARITY. If there are zero positional arguments corresponding to the variadic parameter, an empty array is bound.
    3. 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).
    4. 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).
  • When f resolves only as a value of function type (application to a function-typed parameter, application of the result of evaluating an expression, values with only a Function<...> 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 is E-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 Ti of its corresponding parameter position.
  • The result type R must 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 of runnable(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.
  • runnable resolves 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 runnable or an image option not given as a literal, a run option in a head's parentheses) are reported as E-TYPE-ENV-CONSTRUCT (7.7).

7.6 Static Expansion Order of Syntactic Sugar ​

The expansion order during static verification is as follows.

  1. async e -> spawn(\() -> e)
  2. await e -> core function application await(e) (normalization of the parenthesis-omitted form)
  3. c ? a : b -> if (c) { a } else { b } (6.2)
  4. if (c) { ... } else { ... } -> choose(c, \() -> do { ... }, \() -> do { ... }); without else, the false branch is \() -> null, or \() -> do {} for a Void block (6.4)
  5. case (e) { ... } / case { ... } -> nested choose (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 function
  6. for (x: xs) { ... } -> map(xs, \(x) -> do { ... }) (or for_each(xs, \(x) -> do { ... }) when the type of the body block is Void; 6.4)
  7. try ... catch (...) { ... } / finally { ... } -> expansion to expressions using recover / fail (6.9)
  8. Sequential-execution expansion of do { ... }
  9. Classification of environment heads into the local, registry-reference and recipe forms, and expansion of #head{...} -> runnable(#head, ...) (6.7)
  10. Dispatch of command execution expressions with no environment specification (10.9), determining the environment of each from its command string
  11. 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 case arm 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 a null head 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 reference
  • E-NAME-DUPLICATE: duplicate definition
  • E-TYPE-MISMATCH: type mismatch
  • E-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 one case expression denote the same value, or two type heads of one case expression denote the same type, so the later arm is unreachable (6.4)
  • E-TYPE-ILLFORMED: violation of type well-formedness rules (invalid position of Void, recursive type alias, a union member that is not a data type, a case type head on an Any scrutinee that is not a data type, invalid target type of cast; 4.2, 6.4, 15.8)
  • E-TYPE-SECRET-NON-STRING: !! applied to a binding whose type is not String (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 == / !=, a case scrutinee 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 dependency
  • E-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: a confirm entry 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 state A, expression e evaluates to value v.
  • ⟨ρ, A, e⟩ ⇑ err: under environment ρ and asynchronous state A, expression e stops with failure err.

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.

  1. Evaluate f and confirm that it is a function closure.
  2. Evaluate the arguments from left to right in written order (positional arguments, followed by keyword arguments in written order).
  3. 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).
  4. The default-value expression of a keyword parameter is evaluated only when the corresponding argument is not given.
  5. 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 BindStmt evaluates the right-hand side, binds it to the identifier, and carries the environment forward to subsequent statements.
  • An ExprStmt evaluates the expression and discards the value (except for the final statement).
  • The result of the whole do is the evaluation result of the final statement. If it contains no statements, the result is Void.
  • If an intermediate statement fails, subsequent statements are not evaluated and the whole do fails.

8.5 Control Structures (Core Functions) ​

Evaluation of choose(c, t, f):

  1. Evaluate c.
  2. If c = true, evaluate only t().
  3. If c = false, evaluate only f().
  4. 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):

  1. The scrutinee has already been evaluated and bound, so nothing further is evaluated to test a type head.
  2. The bound value is checked against the head type by the runtime type check rules of cast (15.8). The check yields true or false; unlike cast it neither converts the value nor fails.
  3. 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 to Map<T>, or the reverse, is converted before the body runs. The conversion follows a check that has just succeeded, or, in an else arm, a narrowing that leaves one member of the scrutinee's own type, so it cannot fail.

Evaluation of map(xs, body):

  1. Evaluate xs to obtain an array value.
  2. Traverse the elements from left to right, evaluating body(xi) for each element xi.
  3. Collect each result into an array in the same order and return it.
  4. If any iteration fails, stop there and the whole expression fails.

Evaluation of filter(xs, pred):

  1. Evaluate xs to obtain an array value.
  2. Traverse the elements from left to right, evaluating pred(xi) for each element xi.
  3. Collect only the elements whose result is true into an array in the same order and return it.
  4. If any predicate evaluation fails, stop there and the whole expression fails.

Evaluation of reduce(xs, init, f):

  1. Evaluate xs to obtain an array value.
  2. Evaluate init as the initial accumulator value.
  3. Traverse the elements from left to right, evaluating f(acc, xi) for the accumulator acc and each element xi, and take the result as the new accumulator value.
  4. Return the accumulator value after traversal completes. If any application fails, stop there and the whole expression fails.

Evaluation of for_each(xs, body):

  1. Evaluate xs to obtain an array value.
  2. Traverse the elements from left to right, evaluating body(xi) for each element xi and discarding the result.
  3. Return Void after traversal completes.
  4. If any iteration fails, stop there and the whole expression fails.

8.6 Asynchrony (Core Functions) ​

Evaluation of spawn(thunk):

  1. Evaluate thunk as a function value.
  2. Create a new handle h and set A[h] = running.
  3. Submit thunk() for asynchronous execution.
  4. Return h to the caller immediately.

On asynchronous task completion:

  • On normal completion, A[h] = success(v).
  • On failure, A[h] = failure(err).

Evaluation of await(h):

  1. Evaluate h.
  2. If A[h] = running, wait until completion.
  3. If A[h] = success(v), return v.
  4. If A[h] = failure(err), rethrow err.

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:

  1. While some h created during the execution has never been passed to await, all, or race, nor cancelled by timeout (15.7), wait until A[h] is no longer running.
  2. Report each such h as W-ASYNC-UNAWAITED (6.3), in the order the handles were created, with its failure if A[h] = failure(err).

8.7 Command Execution (Core Function) ​

Evaluation of run(env, cmd):

  1. 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.
  2. Evaluate cmd to a string (for command sugar containing interpolation, the interpolation expressions are evaluated from left to right and the finalized string is passed).
  3. Resolve env to an execution environment (10.4).
  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).
  5. After the command completes, construct and return a CommandResult value (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).
  • run fails 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 run is abandoned when the computation it belongs to is cancelled before the command exits: race cancels the computations of the handles that did not finish first, all cancels the computations of the other handles once one has failed (15.6), and timeout abandons 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 run returns no result. Its command execution log records the command as killed in 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.

  1. Evaluate the argument expressions from left to right in written order.
  2. Return an Environment value consisting of the profile (local or docker) and the image the head names, with its image options.
  3. 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.

  1. Evaluate e to an Environment value, then the run option expressions from left to right in written order.
  2. If the profile of e is local and any run option is set, the application fails (E-RUNTIME-VALUE): local takes none.
  3. Return a Runnable value consisting of e and the run options given. An option given null is not set (10.2).
  4. If the evaluation of e or 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) on Environment and Runnable values is determined by structural equality of the profile, the image with its image options, and the set of run options that are set. An Environment and a Runnable with 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:

  1. Evaluate e to obtain a record value.
  2. 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]:

  1. Evaluate e.
  2. Evaluate i.
  3. If e is an array value: if i is 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).
  4. If e is a map value: if key i exists, return the corresponding value. If absent, it is a runtime error (E-RUNTIME-ACCESS). The failure contract is identical to that of get(m, k) (15.4).
  5. If e is 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.
  • choose propagates only the failure of the selected branch.
  • map / filter / reduce / for_each return the first failure that occurs as the failure of the whole.
  • await rethrows the corresponding asynchronous failure.
  • recover catches 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: code is 2 (the same value as the exit code classification of 11.3).
  • External I/O error: code is 3 (same as above).
  • Giving up on waiting: code is 124, the exit status of GNU timeout, for both a timeout whose limit passed and an until whose delays ran out (15.7). Neither is a failure of the program being waited for, so neither takes the runtime error code 2, and a handler can tell the two cases apart from other failures by the code alone.
  • Failure via fail(err): the given err is used as is. Failures arising from non-zero exits of the command execution expressions $, $1, and $2 fall under this case; by the sugar expansion (6.6), code = the command's exit code and message = the contents of standard error output (signal mapping and capture limits follow the CommandResult construction rules of 8.7).
  • Secret information contained in message is masked or removed according to the rules of 12.8.

Evaluation of recover(body, handler):

  1. Evaluate body().
  2. If it succeeds, return its value. handler is not evaluated.
  3. If it fails, map the failure to an Error value err, evaluate handler(err), and return its result.
  4. Failures during the evaluation of handler are not caught by this recover and propagate outward.

Evaluation of fail(err):

  1. Evaluate err to obtain an Error value.
  2. Raise a failure carrying that Error value.

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 Error value and terminates the process. The process exit code is the code of the Error value (11.3, 14.8).
  • When reflecting into the exit code, if code is not an integer between 1 and 255 (0, negative, non-integer, or greater than 255), it is normalized to 1. The Error value 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).

  1. Read stdin until EOF.
  2. Fix the entire input that was read as a single input snapshot.
  3. Decode the input snapshot to a UTF-8 string by the rules of 9.4 and produce a String value.
  4. 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:

  • stdin is treated as a reserved identifier and must not be rebound by user definitions.
  • The binding of stdin is established exactly once per execution unit.
  • The visibility of stdin takes effect from the top-level environment at the start of evaluation, and it is referenceable according to the ordinary name resolution rules (7.2).
  • stdin is treated as an immutable value; destructive updates are not permitted.
  • stdin cannot be used in repl. Because no binding is provided, a reference is an undefined reference error (E-NAME-UNDEFINED).

Type rules:

  • The static type of stdin is fixed to String. 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 of from_json (Any) to a concrete type uses cast (15.8) in combination, or case type 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 stdin and 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 eval completes function execution, the evaluation result is output to stdout in the specified encoding scheme (11.3).
  • run does not output the evaluation result to stdout. The main purpose of run is task execution, and observation of results is done via execution logs and execution events (Chapter 12).
  • If the evaluation result of eval is Void, 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 stderr of CommandResult (8.7) and into the message of 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 |> f is sugar for f(e), passing the left-hand result to the right-hand function.
  • f >> g denotes function composition and is treated at evaluation time as a single data flow that passes the result of f to g.
  • map(xs, body) aggregates element-wise data flow into an array. When the result is not needed and the purpose is side effects, use for_each(xs, body).

Rules for connecting to I/O:

  • The only entry point from external input into internal expressions is the stdin binding.
  • 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 String values.

Example:

lask
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:

bash
# 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="..."
lask
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-identity

Form:

txt
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_SECRETS lists the schemes a run may resolve, separated by commas or white space (LASK_SECRETS=vault). A reference whose scheme is not listed is E-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_env and get_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 particular find_env does not return Null for it: the variable is set, and what it names must exist.
  • has_env does 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 local command 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):

txt
VaultBody = Path , [ "?version=" , digit , { digit } ] , "#" , Field ;
Path      = Name , { "/" , Name } ;
  • The path is written as vault kv get takes 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 and version selects a version, the latest when omitted. On any other mount the path is read as written, and a version is E-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, and VAULT_CACERT, which trusts the certificate authority in that file instead of the system's.
  • Credentials are, in order of precedence: VAULT_TOKEN; AppRole login with VAULT_ROLE_ID and VAULT_SECRET_ID; the token vault login leaves in ~/.vault-token. None of them is E-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 307 or 308 with which a standby node names the active one, as the Vault CLI follows it. Any other redirect, a second one, or one from https to http is not followed and is E-IO-SECRET-UNREACHABLE.

Errors (14.6), all catchable and exiting with code 3 when uncaught (14.8):

CodeCondition
E-IO-SECRET-PROVIDERThe scheme is unknown or not enabled in LASK_SECRETS, or the store is not configured.
E-IO-SECRET-REFThe reference is malformed.
E-IO-SECRET-UNREACHABLEThe store cannot be reached or does not answer, or cannot serve reads (for Vault: sealed or uninitialized).
E-IO-SECRET-AUTHLogin fails, or the credentials do not allow reading the path.
E-IO-SECRET-NOT-FOUNDThe 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:

  • Environment must not be overridden by user-defined type aliases or value declarations.
  • Environment values 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 env argument of run must be normalized to an Environment or Runnable value 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 run follow the definition in 7.5.
  • Passing a value other than Environment or Runnable to the env argument 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.
  • 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 its latest. 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 options context, naming its build context, and build_args, supplying the build arguments the recipe hash covers (10.3). context defaults to the directory containing the Dockerfile. context and build_args given to a registry reference are static errors (E-TYPE-ENV-CONSTRUCT), because there is then no build for them to reach.
    • Image options: platform (String) for either form, and context (String) and build_args (Map<String>) for a recipe. They decide which image is materialized and run: platform selects 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 context are 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 a Runnable. 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 run options this implementation provides:

GroupOptions
Resource limitsmemory, memory_swap, memory_reservation, cpus, cpu_shares, cpuset_cpus, cpuset_mems, pids_limit, shm_size, blkio_weight, ulimits
Execution contextworkdir, user, env, hostname, init
Confinementread_only, tmpfs, cap_drop
Networknetwork, dns, dns_search, add_hosts, publish
Host filesystemvolumes
  • ulimits, tmpfs, cap_drop, dns, dns_search, publish and volumes are Array<String | Null>; env and add_hosts are Map<String | Null>; init and read_only are Bool | Null; cpus, cpu_shares, pids_limit and blkio_weight are Number | Null; the rest are String | Null.
  • A list or table option also takes a value already typed Array<String> or Map<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 null is left out, as if it had not been written; so is a null element of a list or a null value 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.
  • workdir is the explicit working directory 10.5 gives precedence over the default.
  • env is the explicit specification of 10.6, and is therefore the highest-precedence source of the variable set.
  • A value of env must not appear on the command line of a process lask starts, which every user of the host can read: env is 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, or PATH, HOME or SSH_AUTH_SOCK, and, by engine:
    • docker: a name beginning with DOCKER_.
    • podman: a name beginning with CONTAINER_, CONTAINERS_, PODMAN_, BUILDAH_ or XDG_, or TMPDIR or REGISTRY_AUTH_FILE.
    • nerdctl: a name beginning with CONTAINERD_, NERDCTL_, BUILDKIT_, CNI_ or XDG_, or TMPDIR.
  • read_only, tmpfs and cap_drop narrow the permission boundary of 10.7; publish and volumes widen it, and a module that uses them says so where the environment is written.
  • A Bool parameter left false is the daemon's own default and is not passed; nothing is inferred from its absence.
  • init is the exception: it is on unless set to false. 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 = false runs 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 or scratch-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 -c on POSIX systems and cmd.exe /c on 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 platform is 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 platform if one is given. The resulting image is tagged lask/<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, and cmd must 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.
  • repl is 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:

  1. Evaluate the env expression to obtain an Environment value (profile, image and options).
  2. Classify the value's profile as one of local / docker.
  3. For docker, resolve its image to the image recorded in the lock file (10.3).
  4. Normalize into an executable concrete runtime configuration.

Resolution rules:

  • A docker value 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 is E-IO-IMAGE-MISSING, and the diagnostic names lask sync. An image is told absent only by a daemon that answers: when the daemon cannot be reached, it is E-IO-ENV-RESOLVE, which lask sync would not mend.
  • A reference written with a digest resolves to itself, entry or not.
  • Every image a docker value 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 Environment value, 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 local is the project base directory where execution started.
  • The default cwd for docker is the working directory determined by the implementation inside the container.
    • This implementation bind-mounts the project base directory, read-write, at /work in the container and uses /work as 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.
  • 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:

  1. Obtain the execution environment's default variables as the base set.
  2. Apply override variables from implementation settings or CLI specification.
  3. 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:

  • local is bounded above by the calling process's privileges.
  • docker is 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:

lask
showLocal() = $[#local] pwd

showDocker() =
  $[#alpine:3.20] pwd

showBuilt() =
  $[#./infra/Dockerfile(context = ".")] pwd

10.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 in 2>&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/npm is 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 D be 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 D is 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 D has 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.
  • #local participates on equal terms. A command string invoking both a program registered on #local and 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 stringcommand wordsselection
go test -v ./...go#golang:1.25
go test ./... 2>&1 | tee loggo, tee#golang:1.25
cd web && npm ci && npm testcd, npm, npmthe Node image
pip install -r req.txt && python -m unittestpip, pythonthe Python image
FOO=1 PATH=/x npm cinpmthe Node image
MSG="hello world" npm cinpmthe Node image
npm ci --prefix $(pwd)/webnpm, pwdthe Node image
ls distls#local
`which go` testnone (the first word contains a nested region)nothing
sudo go buildsudonothing
env FOO=1 go testenvnothing
#{bin} -chdir=infra initnone (undeterminable)nothing
ls dist && npm publishls, npmE-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_ENGINE selects one by name (LASK_CONTAINER_ENGINE=podman), in any case.
  • When LASK_CONTAINER_ENGINE is unset or empty, the engine is the first of docker, podman and nerdctl whose client is found on PATH, and docker when 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), and nerdctl (containerd, with BuildKit for recipes). The client is found on PATH under 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 version for 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 env option are given to the client by name, as 10.2 requires, except those the client reads itself (10.2), which differ by engine.
  • 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 env given that way does not reach the container. Such a client is outside what this implementation supports for env.

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 to run (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 .lask files 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:

text
lask <subcommand> [options]

Common options:

  • --module <path>: specifies the target module for execution. When unspecified, main.lask in 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. For run / eval, when a function name is given, the help of that function is displayed (11.6).

Minimal syntax per subcommand:

text
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 (--env etc.) 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 Environment and 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 --function option 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:

text
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 run and eval is identical. The rules of this section (function-name mapping, keyword arguments, binding, decoding) apply equally to both.
  • When --module is unspecified, main.lask is the target. Therefore the minimal form is lask run <function> [args ...] / lask eval <function> [args ...].
  • Options of lask itself (--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 between lask options and function keyword arguments).
  • The sole exception to this boundary rule is --help (11.6). When --help appears 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.
  • -h is not intercepted after the function name, because it would collide with the short form of a single-character keyword parameter. -h is accepted only before the function name.
  • For cmd, no token after the command name is intercepted, --help included: those tokens are the invoked program's own arguments (11.8). The help of the subcommand is obtained with lask cmd --help, with no command name.
  • -- after the function name ends the scan for --help and 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 parameter help in 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-version resolves the top-level function show_version, and --out-dir dist binds to the parameter out_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 (buildAndTest etc.) are not subject to the mapping and are specified with their names as is.

Keyword argument forms:

  • A keyword argument for parameter p is 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 -1 is never a short form: it is a positional argument (a negative number under auto or json decoding).
  • Example: for retry(cmd: String, --n: Number = 3), lask run retry "make test" -n 5 corresponds to the function call retry("make test", n = 5).

Flags:

  • A keyword parameter of type Bool or Bool | Null is a flag. Written without a value (--dry-run, or -v for a single-character name), it binds true, whatever the decoding mode.
  • A flag takes the token after it as its value only when that token is exactly true or false, so --dry-run true and --dry-run false keep their meaning. Any other token after a flag is read as if the flag were not there: lask run tag --all v1 binds all = true and the positional argument v1.
  • --flag=<value> gives the value in the token itself, as for any keyword argument, and is the form to use for null.
  • A positional argument that is the text true or false cannot directly follow a flag; it is written before the flag, or after --.

Binding rules:

  1. Resolve the target module (--module, default main.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 marked internal is not reachable.
  2. 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).
  3. Bind keyword arguments to parameters with matching names. Unknown names and duplicate bindings are pre-execution errors (corresponding to E-TYPE-KEYWORD in 7.5).
  4. 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').
  5. 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 as String.
  • --arg-decode json: interprets each argument as a JSON value.
  • --arg-decode auto: JSON if interpretable as JSON, otherwise String.
  • 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 auto mode, when ambiguous, String takes 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 8080 binds a Number and --n null binds Null under auto or json, while under text both are String and 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 B is instantiated at B, 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.
  • Functions with positional parameters of type Environment or Runnable are 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 a String etc. 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 to runnable (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 when condition 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, a lask option 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_CONFIRM may be set to tty, where runs without a terminal (an agent's, for instance) must not confirm: --confirm is 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 code 4. Its message names the function and the expected phrase.

Difference between run and eval:

  • The only difference between run and eval is the handling of the evaluation result. eval outputs the evaluation result to stdout, and run does not (11.3).
  • The semantics of binding, evaluation, failure propagation, and exit codes are identical for both.
  • eval is intended for passing return values via shell pipes to other commands or another lask invocation, composing tasks ad hoc. For one-off task execution, the output of return values is noise, so run is 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 stdin as a String value (9.2–9.4). No option to select a decoding mode (--stdin-decode etc.) is provided.
  • Structuring such as line splitting or JSON interpretation is performed inside the function by applying split / from_json etc.
  • run / eval accept stdin.
  • repl uses stdin for interactive input, and therefore does not provide the stdin reference variable (9.2, 9.3).
  • check / serve may accept stdin, but the meaning must be defined in the subcommand specification.
  • cmd connects stdin to the invoked program rather than binding it as a value (11.8). It evaluates no function, so the stdin reference 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.
  • text is human-readable display, json is machine-readable display, and pretty-json is formatted JSON display.
  • eval outputs the evaluation result to stdout with the specified encoding.
  • run does not output the evaluation result to stdout. run must not output anything to stdout.
  • cmd produces 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 of lask cmd can be piped.
  • The one exception is help display (11.6). When --help is given, no function is evaluated, so the contract for evaluation results does not apply and run writes the help to stdout.
  • check outputs 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 json is 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 code of the Error value (8.10). Command failure passes through that command's exit code; other runtime errors default to 2; external I/O errors (including image materialization failure and environment resolution failure) default to 3; fail passes through the specified value. If code is not an integer in 1–255, it is normalized to 1.
  • 1: syntax or static validation error (detected before evaluation; no Error value 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 1 or 4, 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:

text
# 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 provision

11.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:

text
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 a platform lists the platforms it is used with, native for the daemon's own. The content-hash directory of a cached dependency in a path is shown as ….
  • KIND: local, registry or recipe.
  • 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: ok for local; for a docker environment present, 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 docker status is ?.
  • envs list reports; it does not fail. A missing or unpinned image, or a daemon that cannot be reached, is a status, and the exit code is 0.
  • After the table, one line counts the environments by kind and by status.

Output and exit codes:

  • The table is on stdout. With --format json it is an array with one object per environment: environment, kind, pinned (in full, or null), required_by (every place, in full) and status.
  • Exit codes: 0 when the environments were listed; 1 for static errors (invalid module, invalid lock file); 4 for CLI usage errors (nonexistent function name, etc.) (following the classification in 11.3).

Execution example:

text
$ 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.json

envs commands enumerates the command executions reachable from the module and reports, for each execution environment, the programs they run.

Syntax:

text
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 of envs 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_container body 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, done or esac, 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 is for, case, select or in contributes 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 to run is a value, not written text, and has the one program (computed).

Columns:

  • ENVIRONMENT: the head, in the notation of envs list, or (dynamic). Environments are listed #local first, 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 of envs 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, or command "<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 json the output is the same whether or not --verbose is given: an array with one object per environment: environment, kind (local, registry, recipe or dynamic), programs (an array of program and used_by, every place in full) and commands (an array of command, programs, module, line, declaration and via, the environment expression, null when it is the head of the environment).
  • Exit codes: as for envs list.

Execution example:

text
$ 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 command

11.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:

text
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 .lask file 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 .lask file under the project directory counts, not only the modules the entry module reaches, so that a module run with --module keeps what it imports. Hidden directories (the module cache among them), node_modules, and a subdirectory with a lask.json of its own (another project) are not searched. A file that does not parse is searched for from "<name>" as text.
  • A transitive dependency is used when the dependency that declares it is.

Rules (list):

  • list reports 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> or url <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 parent for 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 is E-MODULE-LOCK-STALE (Chapter 5), and for which sync pins 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 by sync (E-MODULE-REV-MOVED).
    • unused (lask sync --prune): a direct dependency no .lask file of the project imports.
    • not cached (lask sync): the cache does not hold the content the lock pins.
    • ok.
  • list reports; it does not fail on a status. After the table, one line counts the dependencies, direct and transitive, and their statuses.

Rules (graph):

  • graph shows 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>, only n levels below the entry module are shown.

Rules (add):

  • add resolves the specified source, computes its content hash, verifies the policy, records the entry under <name> in lask.json (creating the file if it does not exist), records the resolution in lask.lock.json, and places the verified source in the cache.
  • <name> must conform to lower_id (3.2). If an entry with the same name already exists, it is replaced.
  • Before recording, add presents 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).
  • --git accepts --rev (a tag or a full commit SHA); when omitted, the repository's default branch head is resolved once and pinned. --url accepts an archive or a single .lask file (Chapter 5).

Rules (rm):

  • rm removes a direct dependency: its entry in lask.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 .lask file 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 one LASK_CACHE_DIR names, which other projects may share.

Output and exit codes:

  • list and graph write 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 in add (network failure, E-MODULE-HASH-MISMATCH, E-MODULE-REV-MOVED).
  • 4: CLI usage error, including rm of a dependency still imported or not declared, and graph of 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:

text
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 run and eval provide 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 --help displays the help of show_version.
  • A function that declares type parameters (4.2) is displayed with them and their bounds, as first<T> or largest<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 of lask 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), so FunctionRef (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, --help still displays the help. That parameter can be supplied only as --help=<value>. An implementation may report the advisory diagnostic W-CLI-PARAM-SHADOWED (14.2).
  • Help display takes precedence over argument binding. Binding errors (11.2) are not reported when --help is present: lask run build --out_dir 1 --help displays the help and exits 0.

Sources of help information:

  • The documentation comment of the declaration (3.1) supplies the summary, the description, and the @param / @return / @example texts.
  • 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 as confirm (null otherwise). 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_dir is 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, #local only, 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:

lask
// 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:

text
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:12

lask run --help appends the function list to the CLI option help:

text
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.
  uninstall

JSON output (--format json) is the structured form of the same information, intended for reuse by editor integration.

json
{
  "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"]
}
  • kind is function-help for the help of a single function, and function-list for the function list. The latter holds functions, an array of objects with name, summary, and signature.
  • kind for a parameter is one of positional / variadic / keyword.
  • default is null for a parameter that has no default value, and "<secret>" for a parameter marked !!.
  • required is true for a required keyword parameter (6.1), and false for 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 as E-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 is 0. Help is most needed while a module is broken, so this is a deliberate exception to the rule of 14.4 that static errors stop run / eval; no function is evaluated in this case either.
  • For lask run --help / lask eval --help without a function name, the CLI option help must always be displayed with exit code 0. 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:

text
lask sync [--module <path>] [--frozen] [--prune]

Rules (modules):

  • sync resolves 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 rev is resolved to a full commit SHA once and pinned thereafter. A subsequent resolution of the same reference to a different SHA is E-MODULE-REV-MOVED.
  • Fetched sources are stored content-addressed; re-running sync with an unchanged project file performs no network access for already-verified entries.
  • A hash verification failure is reported as E-MODULE-HASH-MISMATCH and 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 .lask file of the project imports (11.5) is reported with a warning. With --prune it is removed first, as deps rm removes one (11.5): from lask.json, from the lock with the dependencies under it, and from the project's own cache.

Rules (images):

  • sync materializes 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 its platform if 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 sync therefore 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.
  • sync executes no command inside the resulting containers, except the probe below.

Rules (command probes):

  • After the images are materialized, sync probes 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/sh reports for each word whether command -v finds 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 as commands.
  • 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, and sync exits with 3. The images and their pins are still recorded, and so is the word as "missing", so that run refuses the commands that use it (Chapter 5) until the declaration or the image is corrected.
  • An image whose /bin/sh cannot 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 change commands is E-MODULE-LOCK-STALE.

General rules:

  • Apart from deps add, which fetches the source it records (11.5), sync is 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 that repl may pull an image (10.3).
  • sync takes no selector: it always brings the whole program in line.
  • --frozen fails 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, sync reports on stderr what it is doing: a Modules and an Images (<n>) heading, and for each module and image, when it starts (resolving, checking, pulling, building) and how it ended (fetched, present, pulled, built, pruned, or failed with 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 json each of these is a JSON Lines event.
  • When it ends, whether it succeeded or not, sync writes to stdout a Modules table — NAME, SOURCE, REQUESTED, REV, HASH, STATUS, TIME, as in deps list (11.5) — and an Images table — ENVIRONMENT, KIND, PINNED, REQUIRED BY, STATUS, TIME, as in envs list (11.4) — then one line counting both by status, the total time, and whether lask.json and lask.lock.json were updated. With --format json the summary is one object, modules and images.
  • 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:

text
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, internal ones 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, so lask cmd docker-compose up resolves the command word docker-compose.
  • A name that is not a command word is a CLI usage error (E-CLI-USAGE), and the diagnostic must name lask 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 lask itself 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 --help must 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 why cmd cannot 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.
  • cmd must not pull or build an image (10.3). An absent image is E-IO-IMAGE-MISSING, and the diagnostic must name the subcommand that materializes it.
  • cmd requires a lock file covering every declared dependency, on the same terms as run / eval / envs (Chapter 5).

Stream rules:

  • The program's stdin and stdout are always those of the cmd process. cmd produces no evaluation result, so its stdout belongs to the program (11.3), and lask cmd can 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 docker profile 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.
  • INT and TERM are 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: 1 for a static error, 3 for an external I/O error, 4 for 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 way lask run --help lists 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:

text
$ 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 0

11.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:

CommandMeaning
:reload, :rRead the target module again (below).
:quit, :q, :exitEnd 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:

text
$ 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())
10

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

text
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: secrets also 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_env and get_env_or in 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:

StageWhat is establishedVault
configThe scheme is enabled in LASK_SECRETS and the store is configured (9.8).VAULT_ADDR, credentials
reachableThe store answers and can serve reads. Reported as skipped for a store that has no such notion.sys/health
authThe credentials log in. The report names the method, the lifetime and the policies of the session.token lookup, AppRole login
each referenceThe 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_SECRETS and 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. --read also reads each reference and checks that its field exists; a credential issued by that read under a lease is revoked at once.
  • It exits 0 when every stage and every reference passed, and 3 otherwise (14.8).
  • With --format json, the report is one JSON document on stdout: {"stores": [...], "malformed": [...]}, where each store has its scheme, target, stages and references, and each stage or reference has a status of ok, NG or skipped, a message, and the error code when it failed.

Example:

text
$ 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.

text
lask fmt [--module <path>] [--check] [<path> ...]
lask fmt --stdin [--check]

Targets:

  • Each <path> is a file, or a directory whose .lask files are formatted, recursively. Without one, the target is the directory of the entry module (--module, default main.lask in 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, catch and finally follow the closing brace of the block before them on its line.

  • Each statement, declaration and case arm is on a line of its own. A block or a case whose 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:

    lask
    greet(name: String): String =
      "  hello, #{name}  "
      |> trim
  • The -> of case arms that the source writes in one column stay in one column.

  • Comments are kept, each with the declaration, statement, case arm 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 check reports 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 0 when nothing failed; with --check, only when every file is formatted already. A file that does not parse, or is not formatted under --check, exits 1; a file that cannot be read or written exits 3 (14.8); a <path> that does not exist is a CLI usage error and exits 4.

Example:

text
$ 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 / error
  • traceId: the identifier defined in 12.5
  • message: human-readable message
  • context: arbitrary auxiliary information (function name, module name, environment kind, etc.)

Output rules:

  • info and below are classified as operational logs, and warn / error as 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 level and message, and the command execution log (12.3) follows the text-format provisions.
  • When --format json is 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 carry severity: "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 / eval execution and cmd (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 as 2| 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.

text
<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). 1 and 2 are 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 the exit line and the killed line. The command string on the start line may be truncated to an implementation-defined length.

Output example:

text
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 0

JSON 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) and env (the metadata representation of 13.1; named environments include name) are required.
  • Relay lines: stream (1 or 2) and message (the line's content) are required. command and env may be omitted (correlated with the start line via exec).
  • Exit line: event: "exit" and code (exit code) are required.
  • Killed line: event: "killed" is required. It has no code: the command did not exit on its own.

Level rules:

  • Relay lines (1|, 2|) and the start line ($) are info.
  • exit is info when the exit code is 0, and warn when non-zero.
  • killed is warn.

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-id is 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 CallEvent when a function call starts.
  • Emit a ReturnEvent on normal termination.
  • Emit a FailEvent on failure termination.

Minimum guarantees:

  • Each CallEvent must ultimately correspond to exactly one ReturnEvent or FailEvent.
  • Event order must preserve causal order for the same function execution.
  • When command execution (run) is involved, the ArgumentsSummary may include an environment kind summary.
  • Even when a failure is caught by try / catch (6.9), the FailEvent corresponding 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 triggers
  • repl: evaluation start/end, summary of the most recent error
  • run / 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 json is 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 explicit mark_secret call (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***istent for x). 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's stdout / stderr (8.7) or to any other value as observed by the running program — an error value caught by try / catch (6.9) included — nor to eval'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 format
  • pretty-json: a pretty-printed format carrying the same information as json
  • text: 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 to FunctionRef in 13.2).
  • Environment, Runnable: not directly serializable (4.5). When output is required, convert to the tagged metadata {"$type":"Environment","kind":"<environment kind>","params":{...}}. kind is the environment kind name, and params is 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 | Null therefore maps to a JSON string or to JSON null, and nothing records which member was chosen.
  • Deserialization does not reconstruct a union: from_json / decode produce Any (15.8), and moving from there to a union is cast (15.8) like any other type.

Rules for Any:

  • Any maps according to the serialization rules of the underlying value.
  • If the underlying value is Void or Environment, convert it to the tagged metadata representation; if the underlying value is a function value, convert it to a FunctionRef (13.2).

Round-trip rules for tagged metadata:

  • from_json / decode (15.8) do not treat the $type field specially, and read tagged metadata as an ordinary object (Record or Map).
  • Therefore, restoring Void, Environment, or function values from their serialized results (round-trip conversion) is not guaranteed.

Numeric rules:

  • NaN, +Infinity, and -Infinity cannot 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 identifier
  • name: function name
  • arity: number of positional parameters (m in 7.5)
  • variadic: set to true if 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:

json
{
  "$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, name may be an implementation-generated identifier (e.g., <lambda@L10C5>).
  • The actual values of closed-over free variables must not be included in FunctionRef by default.

13.3 Execution Events ​

txt
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 / fail
  • traceId: correlation ID for the same execution
  • timestamp: event time
  • function: FunctionRef
  • args: argument summary (required for call)
  • result: return value summary (required for return)
  • error: error information (required for fail)
  • env: execution environment summary (environment kind kind and normalized parameter summary params. For a recipe-form environment (10.2), the Dockerfile path may be included as dockerfile)

Minimum fields of ErrorInfo:

  • code: error code (Chapter 14)
  • message: human-readable summary
  • detail: optional details (within a range that can be safely disclosed)

Causal consistency rules:

  • For the same function execution, exactly one return or fail must correspond after the call.
  • timestamp should 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:

json
{
  "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 error
  • NAME: name resolution error
  • TYPE: type checking error
  • MODULE: module resolution error
  • RUNTIME: error during evaluation
  • IO: external I/O error
  • CLI: CLI usage error

Naming rules:

  • Use only uppercase Latin letters and hyphens.

Representative codes:

  • E-SYNTAX-UNEXPECTED-TOKEN
  • E-SYNTAX-CASE-ELSE
  • E-NAME-UNDEFINED
  • E-NAME-DUPLICATE
  • E-TYPE-MISMATCH
  • E-TYPE-ARITY
  • E-TYPE-CALL
  • E-TYPE-COMMAND-ENV
  • E-TYPE-COMMAND-NOENV
  • E-TYPE-COMMAND-CONFLICT
  • E-TYPE-COMMAND-EFFECT
  • E-TYPE-COMMAND-NAME
  • E-TYPE-COMMAND-DUPLICATE
  • E-TYPE-COMMAND-SYNTAX
  • E-TYPE-COMMAND-SCHEMA
  • E-TYPE-COMMAND-OPTION
  • E-TYPE-ENV-CONSTRUCT
  • E-TYPE-ACCESS
  • E-TYPE-FIELD-DUPLICATE
  • E-TYPE-CASE-DUPLICATE
  • E-TYPE-KEYWORD
  • E-TYPE-ILLFORMED
  • E-TYPE-SECRET-NON-STRING
  • E-TYPE-BOUND
  • E-MODULE-CYCLE
  • E-MODULE-UNRESOLVED
  • E-MODULE-HASH-MISMATCH
  • E-MODULE-CONFIRM-TARGET
  • E-RUNTIME-DIV-BY-ZERO
  • E-RUNTIME-COMMAND-NONZERO
  • E-RUNTIME-ACCESS
  • E-RUNTIME-CAST
  • E-RUNTIME-VALUE
  • E-RUNTIME-REGEX
  • E-RUNTIME-TIMEOUT
  • E-RUNTIME-UNTIL-EXHAUSTED
  • E-RUNTIME-SESSION-CLOSED
  • E-IO-STDIN-READ
  • E-IO-ENV-RESOLVE
  • E-IO-FS
  • E-IO-DATA-DECODE
  • E-IO-SECRET-PROVIDER
  • E-IO-SECRET-REF
  • E-IO-SECRET-UNREACHABLE
  • E-IO-SECRET-AUTH
  • E-IO-SECRET-NOT-FOUND
  • E-CLI-USAGE
  • E-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 a severity field is an error.
  • Representative codes:
    • W-DOC-PARAM-UNKNOWN: an @param in a documentation comment (3.1) names a parameter the declaration does not have.
    • W-CLI-PARAM-SHADOWED: a keyword parameter is named help, so it cannot be supplied as --help from 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, with stage: "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, with stage: "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, with stage: "runtime", a location for the async that started it, and, if it failed, a failure object carrying its code and message.
  • Reporting advisory diagnostics is optional, with one exception: W-ASYNC-UNAWAITED is always reported, because a failure it carries is otherwise invisible. check and serve are the expected places for the static ones; run and eval report 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 code
  • message: human-readable message
  • stage: syntax / static / runtime / io / cli
  • traceId: the identifier of 12.5, when available
  • location: line and column, or span, where possible

Additional requirements related to types and names:

  • For type mismatches, include expected and actual.
  • 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'?, or did you mean 'a', 'b' or 'c'? for several. In JSON output they are the array suggestions, and the notes are the array notes; each is present only when not empty.

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 message says 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: the Error value a program catches keeps the empty message.
  • 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:

json
{
  "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:

text
main.lask:2:10-2:14: E-NAME-UNDEFINED [static]: undefined name: 'helo'
  note: did you mean 'hello'?
json
{
  "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-UNDEFINED
  • E-NAME-DUPLICATE
  • E-TYPE-MISMATCH
  • E-TYPE-ARITY
  • E-TYPE-CALL
  • E-TYPE-COMMAND-ENV
  • E-TYPE-COMMAND-NOENV
  • E-TYPE-COMMAND-CONFLICT
  • E-TYPE-COMMAND-EFFECT
  • E-TYPE-COMMAND-NAME
  • E-TYPE-COMMAND-DUPLICATE
  • E-TYPE-COMMAND-SYNTAX
  • E-TYPE-COMMAND-SCHEMA
  • E-TYPE-COMMAND-OPTION
  • E-TYPE-ENV-CONSTRUCT
  • E-TYPE-ACCESS
  • E-TYPE-FIELD-DUPLICATE
  • E-TYPE-CASE-DUPLICATE
  • E-TYPE-KEYWORD
  • E-TYPE-ILLFORMED
  • E-TYPE-SECRET-NON-STRING
  • E-TYPE-BOUND
  • E-MODULE-CYCLE
  • E-MODULE-UNRESOLVED
  • E-MODULE-DEEP-IMPORT
  • E-MODULE-LOCK-STALE
  • E-MODULE-CONFIRM-TARGET

Rules:

  • If there is one or more static errors, evaluation by run / eval must 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 arithmetic
  • E-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 a get_-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 of cast (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); or runnable was given a run option for a local environment (8.8, 15.5)
  • E-RUNTIME-REGEX: a malformed regular expression pattern (15.3)
  • E-RUNTIME-TIMEOUT: the limit of a timeout passed before its body finished (15.7). Its Error has code 124 (8.10).
  • E-RUNTIME-UNTIL-EXHAUSTED: the delays of an until ran out before its condition held (15.7). Its Error has code 124 (8.10).
  • E-RUNTIME-SESSION-CLOSED: a command was run with the session of a with_container whose 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 FailEvent must 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 failure
  • E-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, as sync probed 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/decode in 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 configuration
  • non-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-recoverable by default
  • External I/O errors are recoverable or non-recoverable depending on the cause

Propagation rules:

  • Expressions containing do / collection functions (map, filter, reduce, for_each) / await follow 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 the code of the Error value (8.10, 11.3).
  • The recoverable / non-recoverable classification 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 code of the uncaught Error value (default 2. Command failures pass through the child process's exit code. 8.10)
  • External I/O errors -> the code of the uncaught Error value (default 3)
  • 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 Environment as a required positional argument (the last one; 15.11), just as run does, 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 String and Array<T>, the array form carries the _array suffix (concat_array, contains_array, index_of_array), and where the same operation is wanted for both Array<T> and Map<T>, the map case is written by composing with keys / values / entries rather 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.

ShapeContractResultMembers
get_…Presence is presupposed. Absence is a failure (E-RUNTIME-ACCESS).Tget, 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 indexfind, find_env, find_index
…_orTotal. The caller supplies the result for the absent case.Tget_or, get_env_or
has_…Presence alone is asked.Boolhas_key, has_env
  • get_ and find_ differ only in what absence means, so the pair is written as two functions rather than one with a flag: get_env is for a variable the task requires, and find_env for one it merely accepts.
  • A find_ function that returns a value reports absence as Null, which is why it needs a union type (4.2). A find_ function that returns a position reports it as -1: a position is a Number, and -1 is 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 of index_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/mod have meaning equivalent to the arithmetic operators of 6.2.
  • If the divisor of div is 0, it is E-RUNTIME-DIV-BY-ZERO.
  • If the right-hand side of mod is 0, it is also E-RUNTIME-DIV-BY-ZERO.
  • min/max return 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 is 0.
  • pow(base, exponent) raises base to exponent. The exponent need not be an integer, and pow(0, 0) is 1. A result that is not a real number (for example a negative base with a fractional exponent) is E-RUNTIME-VALUE.
  • sqrt(x) for a negative x is E-RUNTIME-VALUE.
  • clamp(value, low, high) returns low when value < low, high when value > high, and value otherwise. low > high is E-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.
  • length may count in units of characters, but the implementation must keep the counting rule consistent. index_of and substring count in the same unit as length.
  • The behavior of split/join when the delimiter string is empty may be implementation-defined.
  • concat concatenates 2 strings. To concatenate 3 or more, use nested applications of concat or join.
  • String concatenation is done with concat (or join). + is exclusive to Number, and applying it to String is a type error (6.2).
  • contains(s, needle) is true when needle occurs anywhere in s. An empty needle is always true.
  • starts_with(s, prefix) / ends_with(s, suffix) test the ends of s. An empty argument is always true.
  • index_of(s, needle) returns the zero-based position of the first occurrence of needle, or -1 when it does not occur. An empty needle returns 0. The absent case is a value and not a failure: the result is a position, and -1 is a position no string has (15.1). A search that returns the thing found rather than its position reports absence as Null instead — see find (15.4).
  • substring(s, start, end) returns the half-open range [start, end). Both endpoints are clamped to [0, length(s)], and an end at or before start yields the empty string, so substring never fails. Non-integer endpoints are truncated toward zero.
  • pad_start(s, width, pad) / pad_end(s, width, pad) prepend or append repetitions of pad until the result reaches width characters, truncating the last repetition as needed. When length(s) is already at least width, or pad is empty, s is returned unchanged.
  • repeat(s, n) concatenates n copies of s. n is truncated toward zero, and n <= 0 yields the empty string.
  • lines(s) splits s on \n and removes one trailing \r from 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 the stdout of a CommandResult (6.6).
  • to_string(v) produces the same text that interpolating v into a string would (6.6): a String unchanged, a Number in the canonical form of 13.1, and a Bool as true or false. A runtime value of any other kind, Null included, is E-RUNTIME-VALUE; use to_json (15.8) for structured values.
  • The type of v must be stringifiable (6.6), which is the bound of T. to_string of a String | Null is therefore a static error (E-TYPE-BOUND), and the union is narrowed with case (6.4) first. Any is stringifiable and remains accepted, with the runtime check above as its guard.
  • to_number(s) decodes s, ignoring surrounding whitespace, as a number in the JSON number grammar (13.1). Any other text is E-IO-DATA-DECODE. This is the form to use on a number read out of command output, and it replaces going through from_json and cast.

Regular expressions:

  • The pattern argument of regex_test / regex_match / regex_replace is 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 \s may 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|ab matches ab in the subject ab. 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 in s.
  • regex_match(s, pattern) returns, for the leftmost-longest match, an array whose element 0 is the whole matched text and whose element i is the text of the i-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 with is_empty (15.4) rather than against a null: a successful match always has at least element 0, so the empty array cannot be confused with one, and the result type stays Array<String>.
  • regex_replace(s, pattern, replacement) replaces every non-overlapping match from left to right. In replacement, $0 to $9 stand 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 advisory W-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_each traverse the input array from left to right (evaluation rules in 8.5).
  • reduce requires an initial value.
  • for_each discards each application result and returns Void. It is used for iteration whose purpose is side effects.
  • map, filter, reduce, and for_each are 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).
  • get results in a runtime error (E-RUNTIME-ACCESS) when the key is absent. This is the same failure contract as index access m[k] (6.8, 8.9), and it is what the get_ prefix means throughout the library (15.1). To tolerate a missing key, use get_or, or check in advance with has_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, and is_empty(xs) is size(xs) == 0. Both are array-only: the number of entries in a map is size(keys(m)) and its emptiness is_empty(keys(m)), because the built-in library has no overloading (15.1). The number of characters in a string is length (15.3).

Element access:

  • first(xs) / last(xs) return the first and last element. On an empty array both are E-RUNTIME-ACCESS, the same failure contract as xs[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) is slice(xs, 0, n) and drop(xs, n) is slice(xs, n, size(xs)); both clamp n likewise 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 by orderable, so it is Number or String; any other element type is a static error (E-TYPE-BOUND).
  • sort_by(xs, key) sorts by the value of key applied once to each element, under the same bound on the key type U, and is stable: elements with equal keys keep their input order.
  • String ordering is lexicographic by Unicode code point. This definition is local to sort / sort_by, and does not extend the ordering operators < <= > >= of 6.2, which remain Number-only.

Searching:

  • contains_array(xs, v) and index_of_array(xs, v) compare with the structural equality of == (6.2), so the element type is bounded by comparable; any other element type is a static error (E-TYPE-BOUND). index_of_array returns -1 when the value does not occur.

  • find(xs, p) returns the first element for which p is true, or Null when there is none. p is applied from left to right and no further element is tested once one is true. The result type T | Null is a union (4.2), so the caller resolves it with case (6.4) before using the element:

    lask
    first_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 which p is true, or -1. It reports a position rather than a value, so the absent case is -1 and not Null (15.1). Use find when the element is wanted and find_index when the position is.

  • A type variable inside a union is permitted in the return type of a signature, which is what find relies on, and not in a parameter type (4.4). One consequence of the canonical form of a union (4.2) is worth noting: searching an Array<Any> gives Any | Null, which is Any, so the absent case is then not distinguishable by type. That is inherent to Any, and the answer is the usual one — cast the decoded value to a concrete element type before searching it.

  • every(xs, p) is true when p holds for every element, and is true for an empty array. any(xs, p) is true when p holds for at least one element, and is false for an empty array. Both stop applying p at the first element that decides the result.

  • The name all is not used for every, 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) is flatten(map(xs, f)), with f applied 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 by comparable, as for contains_array.
  • range(start, end) returns the ascending integers from start up to but not including end, and the empty array when end <= start. Both arguments must be integers; a non-integer is E-RUNTIME-VALUE. It is how a for expression (6.4) iterates a number of times, since for traverses an array and has no numeric form.
  • enumerate(xs) pairs each element with its zero-based index as records {index: ..., value: ...}. It is how a for expression iterates with an index available, since the body of for receives only the element.

Map operations:

  • set(m, k, v), remove(m, k), and merge(m1, m2) return new maps and leave their arguments unchanged. set replaces the value when the key is already present; remove returns the map unchanged when the key is absent; merge gives the right operand priority on a key present in both.
  • get_or(m, k, fallback) returns the value bound to k, or fallback when the key is absent. It never fails, and it is the form to prefer over testing with has_key and then calling get, which looks the key up twice.
  • entries(m) returns one record {key: ..., value: ...} per entry, and from_entries(es) is its inverse, giving a later element priority on a duplicate key. The two exist so that a map can be traversed with map / filter / reduce, which take arrays.
  • entries and keys traverse in ascending Unicode code point order of the key, so that a traversal of a map is deterministic. values(m) is map(entries(m), \(e) -> e.value) and follows the same order.
  • map_values(m, f) applies f to 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 parameters
  • shell_quote: Function<String, String>
  • with_container: Function<Runnable, Function<Runnable, T>, T>

Contract:

  • Follows the rules of 6.6, 8.7, and Chapter 10. CommandResult is the built-in type alias defined in 6.6.
  • run succeeds 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) is E-RUNTIME-COMMAND-NONZERO.
  • Environment resolution failure is E-IO-ENV-RESOLVE.

Runnables:

  • runnable(env, k1 = v1, ...) returns env with the run options given (10.2): how the image runs, never which image it is. Each run option is a keyword parameter defaulting to null, and an option given null is not set.
  • #head{k1: v1, ...} is its sugar (6.7).
  • It takes an Environment only. A Runnable is 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 local environment is E-TYPE-ENV-CONSTRUCT when the environment is #local written as the argument, and E-RUNTIME-VALUE when it is reached as a value. runnable(e) with no run option is a runnable for any environment.
lask
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 exactly s: 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_quote is for a command line built as a string, such as one passed to run or to sh -c:
lask
upload(env: Runnable, src: String, dst: String): CommandResult =
  run(env, "aws s3 cp #{shell_quote(src)} #{shell_quote(dst)}")
  • shell_quote is 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_quote write it (every ' as '\''; every \, ", $ and ` preceded by \), and the command line that carries it is masked as well.

Container sessions:

  • with_container(env, body) evaluates body(session) with one container of env kept running for the extent of the call, and returns what the body returns, or fails as it fails. session is a Runnable: a command run with it — by a command execution expression (6.6), by run, 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 unless init is false. It is started at the call, before the body is evaluated: an image that is not materialized is E-IO-IMAGE-MISSING, and an engine that cannot start the container is E-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/sh of its own, so a cd or an export ends with its command. Every command starts with the working directory, the user and the variables of the run options of env (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_container evaluates body(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 at info the start of the session, naming its container and env, and the removal of its container.
  • A session is serialized as the environment env it was started on (13.1).
  • This implementation starts the container with a /bin/sh that 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's exec as /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.
lask
// 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 do block 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/await follow the rules of 6.3 and 8.6.
  • Because await is a reserved word, it can only be applied in the AwaitExpr form (await h); when passing it as a function value, write \(h) -> await h (6.3).
  • all waits for all handles to complete and returns the result array in input order.
  • race returns 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:

  • await on a handle whose computation failed re-raises that failure unchanged (6.3, 8.6): the same Error value, with its code and message, and therefore the same exit code if it goes uncaught (11.3). all and race do the same with the failure they receive.
  • all fails 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.
  • race on an empty array is E-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:

  • Error is the built-in type alias Record<code: Number, message: String> (6.9). It must not be overridden by user definitions.

Semantics:

  • recover / fail follow 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 an Error value equivalent to {code: code, message: message}.
  • The return type T of fail is concretized from the context's expected type (4.4).

Failure rules:

  • fail raises a failure carrying the given Error value. If uncaught, the process exits with code as 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.

lask
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 of n delays allows n + 1 attempts. 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) is count delays of delay: backoff_fixed(2, 3) is [2, 2, 2].
  • backoff_linear(initial, step, count) is [initial, initial + step, ...], count long: backoff_linear(1, 2, 3) is [1, 3, 5].
  • backoff_exponential(initial, factor, count) is [initial, initial * factor, ...], count long: 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, is backoff_jitter (15.13).
  • delay, initial, step and factor must be finite and non-negative, and count a non-negative integer. Anything else, or a strategy growing past a representable delay, is E-RUNTIME-VALUE.

Evaluation of retry(delays, body) and retry_if(delays, when, body):

  1. Every element of delays must be finite and non-negative, or the call fails with E-RUNTIME-VALUE before the first attempt, so that whether a strategy is rejected does not depend on how the attempts go.
  2. Evaluate body(). If it succeeds, return its value.
  3. If it fails, map the failure to an Error value err (8.10). If no delay remains, or, for retry_if, when(err) is false, re-raise the failure unchanged: the same Error value, and therefore the same exit code if it goes uncaught, as await does (6.3). retry retries any failure.
  4. 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 code of the failure, and the delay.
  5. A failure of when is not caught and propagates, as that of a recover handler 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.

lask
// 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)))
  1. Validate delays as retry does.
  2. Evaluate v = body(), then done(v). If it is true, return v. The first check is made at once, without a delay.
  3. If it is false and a delay remains, wait for it and continue at step 2. Each miss writes one line to the execution log.
  4. If it is false and no delay remains, fail with the Error {code: 124, message: ...} and the diagnostic code E-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.
  5. A failure of body or of done is not caught and propagates. Waiting through failures is written explicitly, with $* (6.6) or retry_if.

Evaluation of timeout(seconds, body):

  1. seconds must be finite and positive; fractions are allowed. Anything else is E-RUNTIME-VALUE.
  2. Evaluate body(), measuring the time from the call. If it finishes, successfully or not, before seconds have passed, its result is the result of the call.
  3. Once seconds have passed without it finishing, the body is abandoned: the command it is running is stopped (8.7), and every computation it started with async, directly or through the computations those started, is cancelled, and its commands stopped. The call then fails with the Error {code: 124, message: "timed out after <seconds>s"} and the diagnostic code E-RUNTIME-TIMEOUT (8.10, 14.5). There is no grace setting and no per-environment default.
  4. The failure is raised at the timeout call, not inside the body. A try inside the body does not see it, and its finally does not run, because abandoning the body is not a failure of the body; a try around the call sees it. Clean-up that must run belongs outside the timeout.
  5. 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/decode is a format specification string, and at least json, pretty-json, yaml, toml, csv, and dotenv are accepted.
  • yaml follows 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.
  • toml decodes a table to an object under the same rule as a JSON object, and decodes a date or time as a String, since the language has no date type. Encoding is defined only when the top-level value is an object.
  • csv follows RFC 4180 with a header row. Decoding yields an array of objects keyed by the header, in which every value is a String; 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 only String, Number, or Bool values.
  • dotenv decodes NAME=value lines to an object whose values are all String, 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 one NAME=value line 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 is E-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 for json.
  • Every format represents a decoded object the same way from_json does. The choice between Record<...> and Map<Any> is the implementation's (see the JSON conversion rules below), but one implementation must make it identically for every format, so that cast behaves 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<...> or Map<Any> (implementation choice. However, it must be consistent within the same implementation)
  • Tagged metadata representations (objects with a $type field) are also not treated specially, and are converted as ordinary objects under the above rules. Restoration of Void, Environment, and FunctionRef is not performed (round-trip rules of 13.1).

Type migration via cast:

  • cast(v) checks at runtime whether the value v conforms to the target type T, and if it conforms, returns v as a value of type T. Together with the type dispatch of case (6.4), which runs the same check without failing, it is one of the two means of migration from Any or from a union to a concrete type (4.4). Use cast where any other kind of value is an error, and case where the other kinds are to be handled.
  • The target type T must 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 T is limited to data types (Number, String, Bool, Null, Environment, Any, Array/Map/Record composed of them, and unions of those). A cast to a type containing Void, Function, or AsyncHandle is 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.
  • cast is also the way out of a union when only one member is expected and any other is an error: cast(v) at target String on a v: String | Null succeeds when the value is a string and fails with E-RUNTIME-CAST when it is null. When the absent case is to be handled rather than rejected, use case (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 to T.
  • Map<T>: every value conforms at runtime to T.
  • 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, as Record<...> and Map<T> can by the rule above.
  • Positions of Any within 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 of s in 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 is E-IO-DATA-DECODE on input that is not valid base64 or that decodes to bytes that are not valid UTF-8.
  • sha256(s) and md5(s) return the digest of the UTF-8 bytes of s as lowercase hexadecimal, 64 and 32 characters respectively.
  • md5 is 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; use sha256.
  • 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-USAGE or E-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>. T is String, String | Null, or Null, which registers nothing; any other type is E-TYPE-BOUND.

Semantics:

  • get_env(name) returns the value of the process environment variable name. 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_env is a core function (7.2) and must not be directly declared or overridden by user code.
  • find_env(name) returns the value of name, or Null when it is not set. Its result type is the union String | Null (4.2), which the caller resolves with case (6.4) — or with cast (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 when name is set in the process environment, including when it is set to the empty string.
  • get_env_or(name, fallback) returns the value of name when it is set, and fallback when it is not. It always returns a String, 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, a Bool, or a fallback — and follow the naming rules of 15.1. find_env is the only one of them whose result can be absent, and get_env_or(name, fallback) is exactly the case over find_env(name) that returns fallback for Null.
  • 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_env and get_env_or are ordinary built-in symbols and may be shadowed by a user definition (15.1). Only get_env and mark_secret are core functions.
  • A variable holding a secret reference (9.8) is resolved by get_env, find_env and get_env_or, which return the value it names; has_env does not resolve it.
  • get_env does 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_env and get_env_or behave the same way. !! accepts String | Null (6.10), so a credential that may be unset can be read with find_env and still be masked when present.
  • mark_secret(v) registers v for masking (12.8) and returns v unchanged. When v is null, nothing is registered (6.10).
  • mark_secret is 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_secret has no effect on the type of its argument (String in, String out; String | Null in, String | Null out) 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: dirname of a path that does not exist is still its parent, and normalize_path never 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 when p begins with /.
  • The name path_join carries its prefix because join is 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 Environment as 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 do block 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 are E-IO-DATA-DECODE, because the language has no binary value type (4.1); move bytes with a command, or with base64_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 writes contents as UTF-8 with no trailing newline added. It creates no parent directory: a missing parent is E-IO-FS, and make_dir is 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 is E-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 is E-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 is E-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_dir and glob return 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 for run (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_file writes 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 returns Void. 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.
  • log is 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 run does not print the evaluation result (9.5, 11.3), log is also how an intermediate value is observed during run, where eval is not what is being used.
  • The output is subject to secret masking (12.8): a registered value appearing as a substring of message is masked.
  • The line format follows 12.2 and is implementation-defined. In the JSON log format the argument appears as the message field.
  • log is effectful and is not referentially transparent (15.1). Its position within a do block determines when its line is emitted (6.5, 8.4), and a log in 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) returns n characters drawn uniformly from 0-9 and a-z. n must be a non-negative integer; anything else is E-RUNTIME-VALUE.
  • backoff_jitter(delays) replaces each delay d with 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 is E-RUNTIME-VALUE. Its draws need not be cryptographically secure.
  • uuid and random_string must 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).
  • uuid and random_string exist 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 code and message on each failure path.
  • Attach location and traceId where 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.

lask
hello() = "hello, lask"

This module publishes one zero-argument function hello, returning a String value.

Command and expected behavior:

text
$ lask eval hello
"hello, lask"

$ lask run hello
  • eval outputs the evaluation result to stdout and exits with exit code 0.
  • run executes the function but does not output the evaluation result to stdout (execution logs go to stderr). It exits with exit code 0.

16.2 Functions with Type Annotations ​

An example of functions with type annotations and default arguments is shown.

lask
greet(name: String, --prefix: String = "hello"): String =
  concat(prefix, concat(", ", name))

add(x: Number, y: Number): Number =
  x + y

greet("alice") returns "hello, alice", and greet("alice", prefix = "hi") returns "hi, alice".

Command and expected behavior:

text
$ 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 hi
  • name is a positional parameter and is bound only by position. Specification by name such as --name alice is not possible (a CLI usage error corresponding to E-TYPE-KEYWORD).
  • prefix is a keyword parameter and is bound only by name. When unspecified, it is completed with the default value "hello" (7.5).
  • The arguments of add are bound positionally as Number values, with 1 and 2 decoded by the default --arg-decode auto.
  • The last run example executes with the same syntax and binding as eval, but the evaluation result is not output to stdout, and it exits with exit code 0.
  • lask run greet without the positional parameter name is a pre-execution error and the function is not evaluated (CLI usage error; exit code 4; 11.3).

16.3 Higher-Order Functions and Composition ​

Examples of higher-order functions, the pipe operator, and the composition operator are shown.

lask
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 >> double

pipeline(3) and incThenDouble(3) both return 8.

Command and expected behavior:

text
$ lask eval pipeline 3
8

$ lask eval incThenDouble 3
8
  • incThenDouble is 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).
  • applyTwice has 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). Calling applyTwice(inc, 3) from within the language returns 5.

A declaration that declares type parameters (4.2) is written and used the same way, with the parameters instantiated per call.

lask
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
  }
}
text
$ 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_or is instantiated at Number in the first call and at String in the second. Both calls run the same body: within it, T is opaque (4.4), which is why the body can return an element or the fallback but cannot compare, order or interpolate one.
  • shortest and flag_of call generic functions without naming a type anywhere: the instantiation comes from the argument types (4.4).
  • lask eval first_or ... instantiates T at Any (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, and find_first returns Opt<T>, which expands to T | 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.

lask
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:

text
$ 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 labels is interpreted as a JSON array by the default --arg-decode auto and bound to Array<String>.
  • user.name and cfg.retries are record field accesses (. is exclusive to Record; 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 as get(envMap, "APP_ENV") (a missing key is E-RUNTIME-ACCESS). region performs the same retrieval using the built-in library's get.
  • user, cfg, and envMap are 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.

lask
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:

text
$ lask run ci

$ lask eval ci
"phase:lint,phase:unit,phase:e2e:container-test"
  • In both cases, echo local-build is executed locally, followed by echo container-test executed inside the alpine:3.20 container. run does not output the evaluation result, and eval outputs the return value to stdout.
  • buildAndTest has a parameter of type Environment, so it is excluded from direct CLI invocation (11.2). It is invoked via a function with the environment fixed, like ci.
  • If any command exits non-zero, the failure propagates and lask exits 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):

text
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 0

16.6 Asynchronous Execution ​

Corresponding examples of async (sugar) and spawn/await (core functions) are shown.

lask
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:

text
$ lask eval mergeAB
"AB"
  • fetchA and fetchB are started concurrently by async, and both completions are awaited via await before 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 ​

text
# 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 provision

Expected behavior:

  • lask check: outputs diagnostic results to stdout. Exit code 0 if there are no static errors, 1 if there are.
  • lask run hello: executes the function and exits with exit code 0 without outputting the evaluation result to stdout.
  • lask eval greet alice: outputs "hello, alice" to stdout (the default json display).
  • lask run greet alice --prefix hi: executes with the positional argument alice and the keyword argument --prefix hi bound. 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 code 3.
  • provision: executes commands in a container built from the declared recipe. If the image has not been materialized, an E-IO-IMAGE-MISSING diagnostic is output to stderr and it exits with exit code 3.
  • lask envs list provision: lists the environments provision uses, what the lock resolves each to, and whether each image is on the container engine. Exit code 0 whatever 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.

json
{
  "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 an Error value (code: 3), and the lask process exits with exit code 3. An E-IO-IMAGE-MISSING diagnostic is output to stderr (8.10, 11.3).
  • The FailEvent is 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.

lask
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:

text
$ lask run release
  • On success, it exits with exit code 0 (the return value is not output to stdout. To confirm the value, use lask eval release). The finally block ensures ./tmp has been deleted.
  • If ./release.sh fails with exit code 75, the failure is caught, "retry-later" is returned, and it terminates normally (process exit code 0).
  • Other failures are re-raised by fail(e). If it reaches the top level uncaught, the lask process exits with that command's exit code, and e.message (the command's standard error output) is output as diagnostics (8.10, 11.3).
  • The finally block is evaluated exactly once on every path: success, catch, or re-raise.

Released under the MIT License.