Mirash is a lazy, weak and dynamically typed functional programming language for shell. Mirash's syntax is heavily inspired off the Haskell family of languages, while dropping the safety of typing for use in a shell.
While not completely allergic to typing, Mirash is designed to work with values of which we don't know its type. It is factually true that this issue can be solved by the introduction of a super object or by treating such values as strings, but it was a design decision to opt for a model that has an historical and practical use with the things we find essential in a shell.
Mirash is defined as the following grammar:
(* ─── Lexical ─────────────────────────────────────────── *)
lower ::= [a-z]
upper ::= [A-Z]
letter ::= [a-zA-Z]
digit ::= [0-9]
op-char ::= [+\-*/<>=!&|#$%^~?:.\\] (* @ removed, used for stream-arg *)
ident ::= lower (letter | digit | "'" | '_')*
constructor ::= upper (letter | digit | "'")*
(* Mixfix operator names — underscores mark argument holes *)
(* e.g. if_then_else_ _|_ _+_ _! ¬_ open_with_ *)
name-part ::= (letter | digit | op-char)+ (* one chunk, no underscores *)
mixfix-op ::= '_'? name-part ('_' name-part)* '_'?
number-lit ::= '-'? digit+ ('.' digit+)?
string-lit ::= '"' [^"]* '"'
| '"""' .*? '"""' (* multiline *)
field-assign ::= ident '=' expr
record-lit ::= '{' (field-assign (',' field-assign)*)? '}'
literal ::= number-lit | string-lit | record-lit
comment ::= '--' [^\n]* '\n'
| '{-' .*? '-}' (* nestable *)
(* ─── Layout ──────────────────────────────────────────── *)
(* Like Haskell, braces and semicolons may be omitted.
The lexer inserts implicit '{', ';', '}' based on
indentation after 'where', 'let', 'of', and 'do'.
Explicit braces and semicolons override layout. *)
(* ─── Patterns ────────────────────────────────────────── *)
pat ::= '_'
| ident
| ident '@' pat (* as-pattern *)
| constructor pat*
| literal
| '[' (pat (',' pat)*)? ']'
| '(' pat ')'
(* ─── Definitions ─────────────────────────────────────── *)
rhs ::= '=' expr
| ('|' expr '=' expr)+
where-clause ::= 'where' '{' def (';' def)* '}'
def ::= ident pat* rhs where-clause?
| mixfix-op pat* rhs where-clause?
(* ─── Data declarations ───────────────────────────────── *)
field-decl ::= ident (',' ident)*
data-con ::= constructor '{' field-decl '}'
| constructor '_'*
data-decl ::= 'data' data-con ('|' data-con)*
(* ─── Class & Instance declarations ───────────────────── *)
class-decl ::= 'class' (ident | mixfix-op) digit+
dispatch-tag ::= 'String' | 'Number' | 'Record'
| 'List' | 'Stream' | constructor
instance-decl ::= 'instance' (ident | mixfix-op) dispatch-tag
'where' '{' def (';' def)* '}'
(* ─── Fixity ───────────────────────────────────────────── *)
(* precedence applies to the whole mixfix-op pattern *)
fixity-decl ::= ('infixl' | 'infixr' | 'infix' | 'prefix' | 'postfix')
digit+ mixfix-op+
(* ─── Do notation ─────────────────────────────────────── *)
do-stmt ::= pat '<-' expr
| 'let' '{' def (';' def)* '}'
| expr
do-expr ::= 'do' '{' do-stmt (';' do-stmt)* '}'
(* ─── Expressions ─────────────────────────────────────── *)
(* The Pratt parser operates on a flat sequence of expr-atoms.
It consults the fixity table to resolve mixfix operators,
function application, and precedence uniformly.
Ambiguous parses are a compile-time error.
e.g. if_then_else_ e1 e2 e3 parses as: if e1 then e2 else e3 *)
stream-arg ::= '@' atom
qual ::= pat '<-' expr
| expr
atom ::= ident
| constructor
| literal
| '(' expr ')'
| '[' ']'
| '[' expr (',' expr)* ']'
| '[' expr '..' expr? ']'
| '[' expr '|' qual (',' qual)* ']'
| do-expr
alt ::= pat ('->' expr | ('|' expr '->' expr)+)
expr-atom ::= atom | name-part | stream-arg
expr ::= '\' pat+ '->' expr
| 'let' '{' def (';' def)* '}' 'in' expr
| 'case' expr 'of' '{' alt (';' alt)* '}'
| expr-atom+
(* ─── Imports ─────────────────────────────────────────── *)
import-item ::= ident | constructor | '(' mixfix-op ')'
module-path ::= constructor ('.' constructor)*
import-decl ::= 'import' module-path
('(' import-item (',' import-item)* ')')?
| 'import' module-path 'as' constructor
| 'import' 'qualified' module-path
('as' constructor)?
('(' import-item (',' import-item)* ')')?
(* ─── Top-level ────────────────────────────────────────── *)
export ::= ident | constructor | '(' mixfix-op ')'
module-decl ::= 'module' module-path
('(' export (',' export)* ')')?
top-decl ::= module-decl
| import-decl
| fixity-decl
| data-decl
| class-decl
| instance-decl
| def
program ::= top-decl*
Mixfix resolution: the Pratt parser tokenizes name-part chunks and _ holes. When it
encounters a name-part it checks the fixity table for all registered mixfix-op patterns
that begin with or contain that part at the current precedence level, then greedily
consumes argument expressions to fill holes. Function application binds tighter than any
operator. Ambiguous parses are a compile-time error.
We define the core semantics as follows:
(* =================================================================
Judgment: Env : e ~> Env' : v
Env is a heap mapping variables to unevaluated expressions (thunks)
v is a value in WHNF
================================================================= *)
(* --- Values ----------------------------------------------------- *)
v ::= n -- number (decimal)
| s -- string
| r -- record { f = v, ... }
| \x.e -- lambda
| C v* -- positional constructor (saturated)
| C {f = v}* -- record constructor (saturated)
(* --- Tags ------------------------------------------------------- *)
t ::= Number | String | Record | List | Stream | C
(* --- Core Reduction --------------------------------------------- *)
-------------------------------------------------------------------- (Lit)
Env : v ~> Env : v
Env[x -> e] : e ~> Env'[x -> e] : v
-------------------------------------------------------------------- (Var)
Env[x -> e] : x ~> Env'[x -> v] : v
Env : e' ~> Env' : \x.e Env'[x -> e''] : e ~> O : v
-------------------------------------------------------------------- (App)
Env : e' e'' ~> O : v
Env : e' ~> Env' : f not (f in \)
Env' : e_1 ~> Env_1 : v_1
Env_1 : e_2 ~> Env_2 : v_2
...
Env_(n-1) : e_n ~> Env_n : v_n
-------------------------------------------------------------------- (App-FFI)
Env : e' e_1 ... e_n ~> Env_n : ffi(f, v_1, ..., v_n)
Env : e' ~> Env' : f not (f in \) Env' : s ~> Env'' : v_s
-------------------------------------------------------------------- (App-FFI-Stream)
Env : e' @s ~> Env'' : ffi(f, stdin=v_s)
Env[x -> e'] : e'' ~> Env' : v
-------------------------------------------------------------------- (Let)
Env : let x = e' in e'' ~> Env' : v
Env : e ~> Env' : C v_1 ... v_n
Env'[x_1 -> v_1, ..., x_n -> v_n] : e_k ~> O : v
-------------------------------------------------------------------- (Case-Con)
Env : case e of { ... ; C x_1 ... x_n -> e_k ; ... } ~> O : v
Env : e ~> Env' : C {f_1 = v_1, ..., f_n = v_n}
Env'[x_1 -> v_1, ..., x_n -> v_n] : e_k ~> O : v
-------------------------------------------------------------------- (Case-Rec)
Env : case e of { ... ; C {f_1 = x_1, ..., f_n = x_n} -> e_k ; ... } ~> O : v
Env : e ~> Env' : l l = l_k Env' : e_k ~> O : v
-------------------------------------------------------------------- (Case-Lit)
Env : case e of { ... ; l_k -> e_k ; ... } ~> O : v
-------------------------------------------------------------------- (Case-Wild)
Env : case v of { _ -> e ; ... } ~> Env : e
Env : e ~> Env' : v v does not match p_k
Env' : case v of { p_(k+1) -> e_(k+1) ; ... } ~> O : u
-------------------------------------------------------------------- (Case-Skip)
Env : case e of { p_k -> e_k ; p_(k+1) -> e_(k+1) ; ... } ~> O : u
-------------------------------------------------------------------- (Case-Exhaust)
Env : case v of {} ~> runtime error
(* --- Coercions v ~>_t v' --------------------------------------- *)
(* Fire at force sites when tag(v) /= demanded t *)
-------------------------------------------------------------------- (Co-Num-Str)
n ~>_String show(n)
parse(s) = n
-------------------------------------------------------------------- (Co-Str-Num)
s ~>_Number n
parse(s) fails
-------------------------------------------------------------------- (Co-Str-Num-Fail)
s ~>_Number runtime error
-------------------------------------------------------------------- (Co-List-Stream)
[v_1, ..., v_n] ~>_Stream Cons v_1 (... (Cons v_n Nil))
-------------------------------------------------------------------- (Co-Str-Stream)
s ~>_Stream Cons l_1 (... (Cons l_n Nil)) (l_i are lines of s)
s ~>_String s' s' ~>_Stream stream
-------------------------------------------------------------------- (Co-Num-Stream)
n ~>_Stream stream
-------------------------------------------------------------------- (Co-Num-List)
n ~>_List [n]
-------------------------------------------------------------------- (Co-Str-List)
s ~>_List [l_1, ..., l_n] (l_i are lines of s)
(* List coercions take the head element only; tail is discarded *)
v_1 ~>_Number n
-------------------------------------------------------------------- (Co-List-Num)
[v_1, ..., v_n] ~>_Number n
v_1 ~>_String s
-------------------------------------------------------------------- (Co-List-Str)
[v_1, ..., v_n] ~>_String s
-------------------------------------------------------------------- (Co-List-Num-Empty)
[] ~>_Number runtime error
-------------------------------------------------------------------- (Co-List-Str-Empty)
[] ~>_String runtime error
(* Record coercion: a record of constructor A coerces only to
Record A — no cross-constructor coercion is defined.
Non-record values have no coercion path to Record. *)
(* Stream-to-List coercion is intentionally absent;
streams may be infinite. *)
(* --- Coerce meta-rule ------------------------------------------- *)
(* Wires coercions into main semantics at any force site *)
Env : e ~> Env' : v tag(v) /= t v ~>_t v'
-------------------------------------------------------------------- (Coerce)
Env : e ~> Env' : v'
Expressions for the core language are well-defined. The first pass is responsible for desugaring a well-formed Mirash program into a well-defined Mirash Core program. Thus, all non-essential expressions like mixfix operators or do notation are purely represented as standard functions and values.
Desugaring rules:
- Mixfix operators desugar into plain function application.
if e1 then e2 else e3becomesif_then_else_ e1 e2 e3. - Do notation desugars
<-binds intoflatMapcalls:do { x <- e1 ; e2 } ~> flatMap e1 (\x -> e2) do { e1 ; e2 } ~> flatMap e1 (\_ -> e2) do { let { d } ; e } ~> let { d } in e do { e } ~> e - Guards in definitions desugar into nested
caseon boolean values. - List comprehensions desugar into nested
flatMapandfiltercalls.
Unbound functions are executed by the runtime as eager FFI calls to shell commands.
Modules are resolved first from MIRASH_MODULE_DIRECTORY, then from the local directory.
The first match wins; if a local module shares a name with one in the module directory,
the module directory takes priority. Module shadowing is possible. We encourage
namespacing modules with dots, like in Haskell:
module Library.A.Component
import Core.B.Component (function)
Unbound function calls are lifted into shell commands. While Mirash is lazy, FFI calls are eager — the external program runs to completion when forced, since there is no way to impose lazy evaluation on external processes.
Given a definition such as x = curl "https://example.com", x is a Stream that, when
evaluated, will either coerce into a string or operate as a Stream.
Streams are primitives given by the runtime, injected over the core language. They work on outputs and inputs from the real world and expose the following primitives:
codeOfStream -- returns the exit code of a stream
map -- standard map
flatMap -- standard flatMap
filter -- standard filter
pure -- wraps a value into a stream
Arguments that accept piped input are supplied with @: grep "<pattern>" @input.
Streams are implemented directly in the Mirash Runtime.