Muse's Design Goals
Muse is a dynamic language designed to allow Rust developers a lightweight way to run untrusted scripts. It is designed to be familiar to users of other dynamic programming languages, while also feeling inspired by Rust.
Muse aims to achieve these overarching goals with the goals and themes described in the remainder of this chapter.
Executing Untrusted Code Safely
One of the most famous unsolved computer science problem is P vs NP, sometimes referred to the halting problem. To summarize, currently we believe it is impossible to determine if a piece of code terminates if the code is written for a Turing-complete language. Most programming languages, including Muse, are Turing-complete.
Muse provides three approaches that can be combined for allowing applications to run untrusted code while not allowing Muse to consume more resources than allowed.
- Virtual Machine Isolation + Limits: Each virtual machine is fully isolated and has its own configurable limitations. It should be impossible to cause a virtual machine to panic: in other words, all panics should be considered bugs in Muse. Panic-free execution is a work in progress, and additional limits are needed to achieve this goal.
- Budgeted Execution: Each virtual machine can be allocated a budget. Each instruction the virtual machine executes will consume some of the budget. When no budget is available, the virtual machine is paused and can be resumed once additional budget is provided.
- Timed Execution: The virtual machine has separate functions for executing and resuming with specific limits on execution time. Budgeted execution on its own is not enough if the application exposes any async functionality to Muse, as an async task may block for an indefinite amount of time without incurring any budget charges.
Bring your own standard library
Out of the box, a Muse virtual machine only supports the types needed to implement the base language:
- Nil
- Booleans
- 64-bit unsigned integers
- 64-bit signed integers
- 64-bit floating point numbers
- Symbols
- Dynamic Types
- Strings
- Regular Expressions
- Lists/Tuples
- Maps/Sets
- Functions
- Modules
The application embedding Muse has precise control over what to expose beyond this basic set of functionality.
The Dynamic Types are types that Muse provides that are implemented using the
CustomType
trait in Rust. By implementing CustomType
for your own types, you
can use Value::dynamic()
to expose your types to Muse.
Additionally, Muse provides two types for exposing functions to Muse:
RustFunction
: A regular Rust function that returns aResult<Value, Fault>
AsyncRustFunction
: A function that returns a type that implementsFuture<Output = Result<Value, Fault>>
Consistent, Minimal Design
Muse aims to have a minimal syntax while still being very powerful. It does this by trying to reuse syntax as much as possible to ensure consistency across all aspects of the language.
The most prevalent example is how pattern matching syntax is not only used in
match
but also in for
, let
, var
, and fn
.
Magical, Yet Predictable
Muse embraces duck typing: if it walks like a duck and it quacks like a duck, then it must be a duck. To support various language features, the compiler generates code that tries to use a value in a specific way. For example, the code generated by these two expressions is identical:
let a = [0, 1, 2];
# Access the first element using indexing.
a[0];
# Access the first element using the `get` function.
a.get(0);
When the compiler generates code for the index operator, it generates code that
invokes get
on the target value with the given parameters. When the compiler
assigns a value through the index operator, the compiler invokes set
on the
target value with the assigned value in the final argument location.
The compiler takes a similar approach when implementing pattern match
destructuring as well as for
loop iteration. If a value responds to the
correct functions, it can be used within those language features.
Let it crash
Muse embraces exception handling with a goal of making it straightforward and concise to add exception handling. This may seem like an odd choice when it's designed to be used by Rust developers, but in a way, Rust also embraces exception handling: panics. Consider what Rust and Muse do when dividing an integer by 0. In Rust, it unwinds the stack to the panic handler. In Muse, it unwinds the stack to the exception handler.
In Rust, it's much easier to check for 0 compared to catching an panic. In Muse,
the try operator (?
) makes it easy to turn any exception into nil
:
# '//' is the integer division operator
(1 // 0)?
Rather than having two types of error handling, Muse attempts to unify all error handling through exceptions and pattern matching.
Future of Muse
- Tagged Enumerations
- Structures
- (Optional) Actor-based runtime similar to BEAM
Distant Future of Muse
We want to bring gradual typing to Muse, where functions are written using a
strongly-typed variant of Muse. We have reserved the slim arrow (->
) operator
for specifying the return type of functions and closures, and the grammar
supports adding ':' <Type>
rules in the necessary location(s).
Optional JIT compilation paired with strongly-typed functions could yield fairly efficient code.
These long-term goals are unlikely to see any progress, as the initial language features are still being implemented.
Language Reference
Grammar Syntax
To specify the allowed grammar rules, this syntax is used:
RuleName: [..];
: A named grammar rule that matches the specified terms.<RuleName>
: A reference to a rule namedRuleName
'text'
: The raw characterstext
( x )
: A grouping of grammar termsx | y
: Eitherx
ory
, withx
having precedence.<x | y>
: Either rulex
or ruley
with equal precedence.x?
: Optionally allowx
x*
: Zero or morex
in a row.x+
: One or morex
in a row.
Program: <Chain>;
Chain: <Expression> (';' <Expression>)*;
Expression: <Assignment> | <InlineIf>;
Assignment: <Lookup | Index> ('=' <Assignment>)*;
InlineIf: <LogicalOr> ('if' <LogicalOr> ('else' <Expression>)?)?;
LogicalOr: <LogicalXor> ('or' <LogicalXor>)*;
LogicalXor: <LogicalAnd> ('xor' <LogicalAnd>)*;
LogicalAnd: <Comparison> ('and' <Comparison>)*;
Comparison:
<BitwiseOr>
<LessThanOrEqual |
LessThen |
Equal |
NotEqual |
GreaterThan |
GreaterThanOrEqual>*;
LessThanOrEqual: '<=' <BitwiseOr>;
LessThan: '<' <BitwiseOr>;
Equal: '=' <BitwiseOr>`;
NotEqual: '!=' <BitwiseOr>;
GreaterThan: '>' <BitwiseOr>;
GreaterThanOrEqual: '>=' <BitwiseOr>;
BitwiseOr: <BitwiseXor> ('|' <BitwiseXor>)*;
BitwiseXor: <BitwiseAnd> ('^' <BitwiseAnd>)*;
BitwiseAnd: <AddSubtract> ('&' <AddSubtract>)*;
AddSubtract: <MultiplyDivide> <Addition | Subtraction>*;
Addition: '+' <MultiplyDivide>;
Subtraction: '-' <MultiplyDivide>;
MultiplyDivide:
<Power>
<Multiply | Divide | Remainder | IntegerDivide>*;
Multiply: '*' <Power>;
Divide: '/' <Power>;
Remainder: '%' <Power>;
IntegerDivide: '//' <Power>;
Power: <Punctuation> ('**' <Punctuation>)*;
Punctuation: <Prefix> <Call | Index | Lookup | TryOperator | NilCoalesce>*
Call: '(' <ExpressionList> ')' <Punctuation>?;
Index: '[' <ExpressionList> ']' <Punctuation>?;
Lookup: '.' <Identifier> <Punctuation>?;
TryOperator: '?' <Punctuation>?;
NilCoalesce: '??' <Punctuation>;
ExpressionList: <Expression> (',' <Expression>)* ','?;
Prefix:
<BlockOrMap |
Tuple |
List |
LogicalNot |
BitwiseNot |
Negate |
Mod |
Pub |
Fn |
Let |
Var |
If |
Literal |
Loop |
While |
For |
Labeled |
Continue |
Break |
Return |
Match |
Try |
Throw> | Term;
Literal: 'true' | 'false' | 'nil';
BlockOrMap: '{' <EmptyMap | BlockBody | MapBody | SetBody> '}';
EmptyMap: ',';
BlockBody: <Chain>?;
MapBody: <Mapping> (',' <Mapping>)*;
Mapping: <Expression> ':' <Expression>;
SetBody: <Expression> (',' <Expression>)*;
Parentheses: '(' <Expression> ')';
Brackets: '[' <ExpressionList> ']';
LogicalNot: 'not' <Prefix>;
BitwiseNot: '!' <Prefix>;
Negate: '-' <Prefix>;
Pub: 'pub' <Mod | Fn | Let | Var>;
Mod: 'mod' <Identifier> '{' <Chain> '}';
Fn: 'fn' <Identifier>? <FnDeclaration | FnMatch>;
FnDeclaration: ('(' (<Identifier>)? ')')? <ArrowBody | Block>;
ArrowBody: '=>' <Expression>;
BlockBody: '{' <Chain> '}';
FnMatch: '{' <MatchBody> '}';
Let: 'let' <VariablePattern>;
Var: 'var' <VariablePattern>;
VariablePattern: <GuardedPattern> '=' <Expression> <VariableElse>?;
VariableElse: 'else' <Expression>;
If: 'if' <Expression> <ThenExpression | Block> <ElseExpression>?;
ThenExpression: 'then' <Expression>;
ElseExpression: 'else' <Expression>;
Loop: 'loop' <Block> ('while' <Expression>)?;
While: 'while' <Expression> <Block>;
For: 'for' <GuardedPattern> 'in' <Expression> <Block>;
Labeled: <Label> ':' <Loop | While | For | Block>;
Continue: 'continue' <Label>?;
Break: 'break' <Label>? <Expression>?;
Return: 'return' <Expression>?;
Match: 'match' <Expression> '{' <MatchBody> '}';
MatchBody: (<MatchPattern> (',' <MatchPattern>)*)?;
MatchPattern: <GuardedPattern> '=>' <Expression>;
GuardedPattern: <Pattern> ('if' <Expression>)?;
Pattern: <PatternKind> ('|' <PatternKind>)*;
PatternKind: <IdentifierPattern | ListPattern | MapPattern | ExpressionPattern>;
IdentifierPattern: '_' | '...' | <Identifier>;
ListPattern: '[' (<Pattern> (',' <Pattern>)*)? ']';
ExpressionPattern: ('<=' | '>=' | '<' | '>' | '=' | '!=') <Expression>;
MapPattern: '{' (<EntryPattern> (',' <EntryPattern>)*)? ','? '}';
EntryPattern: <EntryKeyPattern> ':' <Pattern>;
EntryKeyPattern: <Identifier | Number | String | Symbol>;
Try: 'try' <Expression> ('catch' <MatchBlock | SingleCatch | ArrowCatch>)?;
SingleCatch: <GuardedPattern> <Block>;
ArrowCatch: '=>' <Expression>;
Throw: 'throw' <Expression>?;
Term: <Identifier | Number | Regex | String | Symbol>;
Lexical Analysis
Source code is interpretted as a series of tokens. Whitespace is insignificant to Muse, and any amount of whitespace is allowed between tokens. Comment tokens are also ignored by the Muse compiler. Many of these tokens use Muse's own Regex syntax to represent the allowed characters.
The types of tokens Muse supports are:
-
Identifier: Any valid Unicode Identifier is supported by Muse. Underscores are allowed anywhere in an identifier, and an identifier can be comprised solely of underscores. Ascii digits can be used anywhere except at the beginning of an identifier.
-
Number:
<Integer | Float | Bits>
.- Integer:
\\d+u?/
- Float:
\(\d+\.\d*|\.\d+)/
- Bits:
<Hex | Octal | Binary | Radix>
- Hex:
\0u?x[\da-f]+/i
- Octal:
\0u?o[0-5]+/
- Binary:
\0u?b[01]+/
- Radix:
\\d+u?r[0-9a-z]+/i
- Hex:
- Integer:
-
Regex: Regex supports two formats of Regex literals, both which use
\
to begin the Regex. The end of the Regex is/
. E.g.,\hello/
is a Regex that matcheshello
.The only character that needs to be escaped beyond what Regex normally requires is
/
. E.g.,\hello\/world/
will matchhello/world
.- Expanded:
w\
followed by an escaped Regex where whitespace is ignored, ended with/
- Normal:
\
followed by an escaped Regex, ended with/
These characters can be added after the trailing
/
to customize the Regex options:i
: Ignore caseu
: Enables Unicode mode for the entire patterns
:.
matches all, including newlinesm
: Enables multiline matching
Muse uses the regex crate, which provides excellent documentation about what features it supports.
- Expanded:
-
String: Begins and ends with
"
. Escapes begin with\
and match Rust's supported escape sequences:\"
: Quotation mark\n
: ASCII newline\r
: ASCII carriage return\t
: ASCII tab\\
: Backslash\0
: ASCII null\x
: ASCII escape. Must be followed by exactly two hexadecimal digits.\u
: Unicode escape. Must be followed by up to six hexadecimal digits enclodes in curly braces. E.g,\u{1F980}
is the escape sequence for🦀
.
-
Symbol:
:
followed by a valid<Identifier>
. -
Label:
@
followed by a valid<Identifier>
. -
Comment:
#
followed by any character until the next line.
Expressions
Program: <Chain>;
Chain: <Expression> (';' <Expression>)*;
Expression: <Assignment> | <ArrowFn> | <InlineIf>;
The compiler expects one or more expressions delimited by a ';'. These expressions are evaluated within the root "module". When executing multiple compiled programs in the same virtual machine, the root module will contain declarations from previous programs.
Each expression can be an assignment, an arrow
function, or an inline if.
The precedence of these operators matches the order they are listed in. For
example, these two ways of assigning to my_function
are identical:
var my_function = nil;
my_function = n => n * 2 if n > 0;
my_function = (n => (n * 2 if n > 0));
The definition of inline if recurses into the remaining expression types.
Values, Variables, and Assignment
Assignment: <Lookup | Index> ('=' <Assignment>)*;
Let: 'let' <VariablePattern>;
Var: 'var' <VariablePattern>;
VariablePattern: <GuardedPattern> '=' <Expression> <VariableElse>?;
VariableElse: 'else' <Expression>;
Named values can be declared using either the let
or var
keywords followed
by a pattern binding.
When a let
expression is evaluated, all bound identifiers will not be able to
be reassigned to. In Muse, these are simply called values. While values cannot
be reassigned to, they can be shadowed by another declaration.
# Basic value declaration
let a = 42;
# Values declared using destructuring
let (a, b, _) = [0, 1, 2];
# Handling pattern mismatches
let (a, b) = [0, 1, 2] else return;
# The contents of a value can still be affected (mutated), if the type supports it.
let a = [];
a.push(42);
If the expression does not match the pattern, either the else
expression will
be evaluated or a pattern mismatch exception will be thrown. The else
expression requires that all code paths escape the current block. This can be
done using the break
, continue
, return
, or throw
expressions.
When the var
keyword is used, all bound identifiers become variables. Unlike
values, variables can be assigned new values.
# Basic declaration
var a = 42;
# `var`s can have their values updated through assignment.
a = 42;
# Declarations using destructuring
var (a, b, _) = [0, 1, 2];
# Both a and b can have their values updated
a = 42;
b = 43;
Comparisons
Comparison:
<BitwiseOr>
<LessThanOrEqual |
LessThen |
Equal |
NotEqual |
GreaterThan |
GreaterThanOrEqual>*;
LessThanOrEqual: '<=' <BitwiseOr>;
LessThan: '<' <BitwiseOr>;
Equal: '=' <BitwiseOr>`;
NotEqual: '!=' <BitwiseOr>;
GreaterThan: '>' <BitwiseOr>;
GreaterThanOrEqual: '>=' <BitwiseOr>;
Comparing two values in Muse is done using comparison operators. The types of comparisons Muse supports are equality and relative comparisons.
Equality Comparison
When Muse checks if two values are equal or not equal, Muse will try to approximately compare similar data types. For example, all of these comparisons are true:
let true = 1 == 1.0;
let true = 1 == true;
let true = 1.0 == true;
nil
is only considered equal to nil
and will be not equal to every other
value.
let true = nil == nil;
let false = nil == false;
Relative Comparison
Muse attempts to create a "total order" of all data types so that sorting a list of values can provide predictable results even with mixed types. If a type does not support relative comparisons, its memory address will be used as a unique value to compare against.
Range Comparisons
Chaining multiple comparison expressions together creates a range comparison.
For example, consider 0 < a < 5
. On its own, 0 < a
results in a boolean,
which traditionally would result in the second part of the expression becoming
bool_result < 5
. In general, this isn't the programmer's desire.
Muse interprets 0 < a < 5
as 0 < a && a < 5
. This form of chaining
comparisons can mix and match all comparison operators. Consider a longer
example:
let a = 1;
let b = 2;
# These two statements are practically identical
let true = 0 < a < b < 5;
let true = 0 < a && a < b && b < 5;
Boolean Logic
LogicalOr: <LogicalXor> ('or' <LogicalXor>)*;
LogicalXor: <LogicalAnd> ('xor' <LogicalAnd>)*;
LogicalAnd: <Comparison> ('and' <Comparison>)*;
LogicalNot: 'not' <Prefix>;
Boolean logic allows combining boolean values by performing logic operations.
The logic operations that Muse supports are and
, or
, xor
, and not
.
Truthiness
Muse evaluates each operand's truthiness to perform boolean logic. If a value
is said to be truthy, it is considered equivalent to true
in boolean logic.
If a value is said to be falsy, it is considered equivalent to false
in
boolean logic. Each type is responsible for implementing its truthiness
conditions:
nil
: Always falsey- Numbers: Non-zero values are truthy, zero is falsy.
- Strings: Non-empty strings are truthy, empty strings are falsey.
- Lists/Tuples/Maps: Non-empty collections are truthy, empty collections are falsey.
- Symbol: Always truthy
- Other types that don't implement
truthy
: Always truthy.
Logical Or
The logical or expression is a short-circuiting operator that returns true if either of its operands are truthy.
let true = true or true;
let true = true or false;
let true = false or true;
let false = false or false;
The short-circuiting behavior ensures that once the expression is known to
return true
, no remaining chained expressions will be evaluated:
# "error" is not evaluated
let true = true or error;
Logical Xor
The logical exclusive or (xor) expression is an operator that returns true if one of its operands is truthy, but not both.
let true = true xor false;
let true = false xor true;
let false = true xor true;
let false = false xor false;
This operator can not short-circuit, so both expressions are always evaluated.
Logical And
The logical and expression is a short-circuiting operator that returns true both of its operands are truthy.
let true = true or true;
let true = true or false;
let true = false or true;
let false = false or false;
The short-circuiting behavior ensures that once the expression is known to
return false
, no remaining chained expressions will be evaluated:
# "error" is not evaluated
let false = false and error;
Bitwise
BitwiseOr: <BitwiseXor> ('|' <BitwiseXor>)*;
BitwiseXor: <BitwiseAnd> ('^' <BitwiseAnd>)*;
BitwiseAnd: <AddSubtract> ('&' <AddSubtract>)*;
BitwiseShift: <AddSubtract> <ShiftLeft | ShiftRight>*;
ShiftLeft: '<<' <AddSubtract>;
ShiftRight: '>>' <AddSubtract>;
BitwiseNot: '!' <Prefix>;
Bitwise operations operate on integers using logic operations on each individual bit. In Muse, integers are 64-bits and can be signed or unsigned. Because signed numbers use a bit for determining the sign, it is often preferred to use unsigned numbers only when performing bitwise operations.
The only operator that performs differently between signed and unsigned integers is shift right. Muse uses a sign-preserving shift-right operation for signed integers.
These operators can be overridden by types to perform different non-bitwise functionality.
Bitwise Or
The bitwise or expression produces a new value by performing a logical or operation on each corresponding bit in the two operands.
let 0b101 = 0b100 | 0b001
Bitwise Xor
The bitwise excusive or (xor) expression produces a new value by performing a logical xor operation on each corresponding bit in the two operands.
let 0b101 = 0b110 ^ 0b011
Bitwise And
The bitwise and expression produces a new value by performing a logical and operation on each corresponding bit in the two operands.
let 0b100 = 0b110 & 0b101
Bitwise Shifting
The bitwise shift expressions produce a new value by moving bits left or right by a number of bits, filling in any empty bits with 0.
let 0b100 = 0b010 << 1;
let 0b001 = 0b010 >> 1;
The shift-right expression is sign-preserving when operating on signed integers:
let -2 = -4 >> 1;
Bitwise Not
The bitwise not expression produces a new value by performing a logical not operation on each bit in the operand.
let -1 = !0;
let 0uxffff_ffff_ffff_ffff = !0u;
Math/Arithmetics
AddSubtract: <MultiplyDivide> <Addition | Subtraction>*;
Addition: '+' <MultiplyDivide>;
Subtraction: '-' <MultiplyDivide>;
MultiplyDivide:
<Power>
<Multiply | Divide | Remainder | IntegerDivide>*;
Multiply: '*' <Power>;
Divide: '/' <Power>;
Remainder: '%' <Power>;
IntegerDivide: '//' <Power>;
Power: <Punctuation> ('**' <Punctuation>)*;
Negate: '-' <Prefix>;
Arithmetic Order of Operations
Arithmetic expressions in Muse honor the traditional order of operations. For example, consider these identical pairs of expressions written without and with parentheses:
2 * 4 + 2 * 3;
(2 * 4) + (2 * 3);
2 ** 3 * 2 + 1;
((2 ** 3) * 2) + 1;
Floating Point and Integer Arithmetic
With the exception of the division (/
), integer divide (//
) and remainder
(%
) operators, all math operators will follow these rules for type
conversions:
- If any operand is a
Dynamic
, return the result of invoking the associated function for the operator on that type. - If all operands are of the same numeric type, return the result as the same numeric type.
- If any operand is a floating point, return the result as a floating point.
- Perform the result as integers, using saturating operations, preserving the integer type of the first operand.
The division operator (/
) always returns a floating point result when both
operands are numbers. Conversely, the remainder (%
) and integer divide (//
)
operators always return integer results when both operands are numbers.
Term
Term: <Identifier | Number | Regex | String | Symbol>;
Lookup/Call/Index
Punctuation: <Prefix> <Call | Index | Lookup | TryOperator | NilCoalesce>*
Call: '(' <ExpressionList> ')' <Punctuation>?;
Index: '[' <ExpressionList> ']' <Punctuation>?;
Lookup: '.' <Identifier> <Punctuation>?;
This set of operators acts on a term followed by a way of interacting with that term to either call it like a function, look up a value by index, or lookup a member value by an identifier.
These expression are chainable. For example, this code accesses a value from a
list within a list by chaining the index operator after invoking list.get(0)
function:
let list = [[1, 2, 3], [4, 5, 6]];
let 2 = list.get(0)[1];
Nil and Exception Handling
Punctuation: <Prefix> <Call | Index | Lookup | TryOperator | NilCoalesce>*
TryOperator: '?' <Punctuation>?;
NilCoalesce: '??' <Punctuation>;
Try: 'try' <Expression> ('catch' <MatchBlock | SingleCatch | ArrowCatch>)?;
SingleCatch: <GuardedPattern> <Block>;
ArrowCatch: '=>' <Expression>;
Throw: 'throw' <Expression>?;
Muse embraces exception handling for propagating errors and aims to make handling errors straightfoward when desired.
When an exception is thrown, Muse unwinds the execution stack to the nearest
catch
handler. If the catch handler matches the exception thrown, the
exception handler will be executed. If not, Muse will continue to unwind the
stack until a matching handler is found or execution returns to the embedding
application. In Rust, the result will be Err(_)
.
Any value can be used as an exception, and handling specific functions is done using pattern matching. Consider this example:
fn first(n) {
try {
second(n)
} catch :bad_arg {
second(1)
}
};
fn second(n) {
try {
100 / n
} catch :divided_by_zero {
throw :bad_arg
}
};
first(0)
When the above example is executed, second is invoked with 0
, which causes a
divide by zero exception. The exception occurs within the try
block, and is
caught by the catch :divide_by_zero
block.
The catch block proceeds to throw the symbol :bad_arg
as a new exception.
first()
is executing second()
within a try block that catches that value,
and calls second(1)
instead. This will succeed, and return a result of 100
.
While this is a nonsensical example, it highlights the basic way that exception handling and pattern matching can be combined when handling errors.
Catch any error
Catching any error can be done in one of two ways: using an identifier binding or using an arrow catch block. In the next example, both functions are identical:
fn identifier_binding() {
try {
1 / 0
} catch err {
err
}
}
fn arrow_catch() {
try {
1 / 0
} catch => {
# when not provided a name, `it` is bound to the thrown exception.
it
}
}
Matching multiple errors
To catch multiple errors raised by inside of a try block, a match block can be used:
fn checked_area(width,height) {
if width == 0 {
throw :invalid_width
} else if height == 0 {
throw :invalid_height
}
width * height
};
try {
checked_area(0, 100)
} catch {
:invalid_width => {}
:invalid_height => {}
}
Converting any exception to nil
A try
block without a catch
block will return nil
if an exception is
raised. Similarly, the try operator (?
) can be used to convert any exception
raised to nil
. These examples produce identical code:
try {
1 / 0
};
(1 / 0)?;
Maps and Sets
BlockOrMap: '{' <EmptyMap | BlockBody | MapBody | SetBody> '}';
EmptyMap: ',';
BlockBody: <Chain>?;
MapBody: <Mapping> (',' <Mapping>)*;
Mapping: <Expression> ':' <Expression>;
SetBody: <Expression> (',' <Expression>)*;
Maps are collections that store key-value pairs. The underlying way maps store their key-value pairs is considered an implementation detail and not to be relied upon.
Map literals are created using curly braces ({
/ }
). An empty map literal is
created by placing a comma (,
) between the braces: {,}
. Muse considers {}
to be an empty block, which results in nil
.
Pairs are specified by placing a colon (:
) between two expressions. For
example, consider this map literal and usage:
let map = {
"a": 1,
"b": 2,
"c": 3,
};
let 2 = map["b"];
Sets in Muse are implemented under the hood using the Map type. Set literals can be specified by using a comma separated list of expressions in curly braces:
let set = {
1,
2,
3
};
set.contains(1)
Lists/Arrays/Tuples
Brackets: '[' <ExpressionList> ']';
A sequence of values stored sequentially is a List in Muse. In other languages, these structures may also be referred to as arrays or tuples.
let list = [1, 2, 3];
$assert(list[0] == 1);
$assert(list[1] == 2);
$assert(list[2] == 3);
Functions
Fn: 'fn' <Identifier>? <FnDeclaration | FnMatch>;
FnDeclaration: ('(' (<Identifier>)? ')')? <ArrowBody | Block>;
ArrowBody: '=>' <Expression>;
BlockBody: '{' <Chain> '}';
FnMatch: '{' <MatchBody> '}';
Return: 'return' <Expression>?;
Functions in Muse are parameterized expressions that can be executed on-demand. This is the primary way to reuse code and avoid code duplication.
fn
Functions
There are many forms that fn
functions can be declared.
Anonymous Functions
Anonymous functions do not have a name specified at the time of creation. They can only be called by calling the function returned from the function expression:
let square = fn(n) => n ** 2;
let 4 = square(2);
let area = fn(width, height) => width * height;
let 6 = area(2, 3);
# Function bodies can also be specified using a block.
let square = fn(n) {
n ** 2
};
let 4 = square(2);
let area = fn(width, height) {
width * height
};
let 6 = area(2, 3);
Named Functions
If an identifier is directly after the fn
keyword, the function is declared
using the identifier as its name. These examples are identical to the anonymous
function examples, except they utilize named functions instead of let
expressions to declare the function.
fn square(n) => n ** 2;
let 4 = square(2);
fn area(width, height) => width * height;
let 6 = area(2, 3);
# Function bodies can also be specified using a block.
fn square(n) {
n ** 2
};
let 4 = square(2);
fn area(width, height) {
width * height
};
let 6 = area(2, 3);
Match Functions
Muse supports function overloading/multiple dispatch using pattern matching. If
an open curly brace ({
) is found after the fn
keyword or after the
function's name, the contents of the braces are interpretted as a set of match
patterns:
fn area {
[width] => width * width,
[width, height] => width * height,
};
let 4 = area(2);
let 6 = area(2, 3);
# Anonymous functions can also be match functions
let area = fn {
[width] => width * width,
[width, height] => width * height,
};
let 4 = area(2);
let 6 = area(2, 3);
Returning values from a function
When a function executes, the result of the final expression evaluated will be
returned. The return
expression can be used to exit a function without
executing any additional code.
fn my_function() {
return;
this_expression_wont_be_evaluated
};
my_function()
The return
expression can also be provided an expression to return as the
result of the function. If no value is provided, nil
is returned.
fn checked_op(numerator, denominator) {
if denominator == 0 {
return numerator // denominator;
}
};
let nil = checked_op(1, 0);
let 3 = checked_op(6, 2);
Modules
Mod: 'mod' <Identifier> '{' <Chain> '}';
Pub: 'pub' <Mod | Fn | Let | Var>;
Modules provide a way to encapsulate code behind a namespace. Consider this example:
mod math {
pub fn square(n) => n * n;
};
math.square(4)
The above example creates a module named math
, which contains a function named
square
. The function is then invoked with the value 4
.
Publishing declarations
The pub
keyword can be used to publish modules, functions, values, and
variables. Without the pub
keyword in the previous section's example,
attempting to access the square
function will fail.
The default visibility of a declaration is module-private. Any other module, including submodules, are not be able to access private declarations.
Published declarations are able to be accessed anywhere that the containing module is accessible.
If
If: 'if' <Expression> <ThenExpression | Block> <ElseExpression>?;
ThenExpression: 'then' <Expression>;
ElseExpression: 'else' <Expression>;
InlineIf: <LogicalOr> ('if' <LogicalOr> ('else' <Expression>)?)?;
The if
keyword allows conditionally evaluating code. There are two forms of
the if
expression: standalone and inline.
Standalone If
The standalone if
expression enables executing an expression when a condition
is true:
let 42 = if true {
42
};
let 42 = if true then 42;
let nil = if false {
42
};
let nil = if false then 42;
If the else
keyword is the next token after the "when true" expression, an
expression can be evaluated when the condition is false.
let 42 = if false {
0
} else {
42
};
let 42 = if false then 0 else 42;
Because if
is an expression, the expressions can be chained to create more
complex if/else-if expressions:
fn clamp_to_ten(n) {
if n < 0 {
0
} else if n > 10 {
10
} else {
n
}
};
let 0 = clamp_to_ten(-1);
let 1 = clamp_to_ten(1);
let 10 = clamp_to_ten(11);
Inline If
An inline if expression returns the guarded expression if the condition is
true, or nil
when the condition is false:
let 42 = 42 if true;
let nil = 42 if false;
Similar to the standalone if
expression, else
can be used to execute a
different expression when the condition is false:
let 42 = 0 if false else 42;
Inline if statements can also be chained:
fn clamp_to_ten(n) {
0 if n < 0
else 10 if n > 10
else n
};
let 0 = clamp_to_ten(-1);
let 1 = clamp_to_ten(1);
let 10 = clamp_to_ten(11);
Pattern Matching
Match: 'match' <Expression> '{' <MatchBody> '}';
MatchBody: (<MatchPattern> (',' <MatchPattern>)*)?;
MatchPattern: <GuardedPattern> '=>' <Expression>;
GuardedPattern: <Pattern> ('if' <Expression>)?;
Pattern: <PatternKind> ('|' <PatternKind>)*;
PatternKind: <IdentifierPattern | ListPattern | MapPattern | ExpressionPattern>;
IdentifierPattern: '_' | '...' | <Identifier>;
ListPattern: '[' (<Pattern> (',' <Pattern>)*)? ']';
ExpressionPattern: ('<=' | '>=' | '<' | '>' | '=' | '!=') <Expression>;
MapPattern: '{' (<EntryPattern> (',' <EntryPattern>)*)? ','? '}';
EntryPattern: <EntryKeyPattern> ':' <Pattern>;
EntryKeyPattern: <Identifier | Number | String | Symbol>;
match
expression
The match expression enables concise and powerful ways to inspect and instract data from the result of an expression.
Match block
A match block is one or more guarded patterns and their associated expressions. Any identifiers bound in the match pattern will only be accessible while executing that pattern's guard and associated expression.
When evaluating a match block, the compiler generates code that tries to match each pattern in sequence. If no match is found, a pattern mismatch exception is thrown.
Guarded Pattern
A guarded pattern is a pattern followed by an optional if
condition. If the
pattern matches, the if
condition is evaluated. If the result is truthy, the
pattern is considered a match.
Pattern
A pattern can one one of these kinds:
- Wildcard:
_
will match any value. - Named wildcard: Any identifier
- Remaining Wildcard:
...
will match all remaining elements in a collection/ - An expression comparison: A comparison operator followed by an expression.
- Tuple pattern: A comma separated list of patterns enclosed in parentheses.
- List pattern: A comma separated list of patterns enclosed in square brackets.
Multiple matching patterns can be used by chaining patterns together with the
vertical bar (|
). This example uses a match function to try to demonstrate a
lot of the flexibility this feature provides:
fn test_match {
[a, b] if b != 1 => a - b,
[a, b] => a + b,
n => n,
[] => 42,
_ => "wildcard",
};
let 42 = test_match(44, 2);
let 42 = test_match(41, 1);
let 42 = test_match(42);
let 42 = test_match();
let "wildcard" = test_match(1, 2, 3);
Loops
Loop: 'loop' <Block> ('while' <Expression>)?;
While: 'while' <Expression> <Block>;
For: 'for' <GuardedPattern> 'in' <Expression> <Block>;
Muse has four loop types:
- Infinite loop: Repeats the loop body unconditionally.
- While loop: Repeats the loop body while a condition is truthy.
- Loop-While loop: Executes the loop body and then repeats the loop while the condition is truthy.
- For loop: Evaluates the loop body for each matching element in an iterable value.
Each loop type currently requires that the loop body is a block. All loop's execution can be affected by these operations:
- An uncaught exception
return
break
: Exits the loop body, optionally returning a value.continue
: Jumps to the beginning of the next loop iteration, which is just prior to the iterator is advanced and any conditions being checked.
Infinite Loop
var n = 0;
let nil = loop {
if n % 2 == 0 {
n = n + 1;
} else if n > 50 {
break;
} else {
n = n * 2;
}
};
let 63 = n;
The above loop executes a series of operations until n > 50
is true, at which
point the loop exits.
While Loop
A while loop checks that a condition is truthy before executing the loop body, and continues repeating until the condition is not truthy.
var n = 0;
while n < 10 {
n = n + 1;
};
let 10 = n;
Loop-While Loop
A Loop-While loop executes the loop body, then checks whether the condition is
truthy. This is different from a While loop in that the condition is only
checked after the first iteration of the loop. The continue
expression will
continue iteration just prior to the condition evaluation.
var n = 1;
loop {
n = n * 3;
} while n % 4 != 1;
let 9 = n;
For loop
A For loop iterates a value and executes the loop body for each matching element in the iterator. The syntax for the for loop uses a guarded pattern, which means all pattern matching features can be used in the for loop syntax.
If the iterator returns an item that does not match the pattern, the next element will be requested and the loop body will not be executed for that element.
var sum = 0;
for n in [1, 2, 3] {
sum = sum + n
};
let 6 = sum;
for (key, value) in {"a": 1, "b": 2} {
match key {
"a" => let 1 = value,
"b" => let 2 = value,
}
};
var sum = 0;
for (_, value) if value % 2 == 1 in {"a": 1, "b": 2, "c": 3} {
sum = sum + value;
};
let 4 = sum;
Break, Continue, and Labels
Labeled: <Label> ':' <Loop | While | For | Block>;
Continue: 'continue' <Label>?;
Break: 'break' <Label>? <Expression>?;
Muse does not have a "go to" capability, but it offers several expressions that
can jump to well-defined execution points: continue
and break
.
The well-defined execution points are:
- A loop's next iteration
- The first instruction after a loop body
- A labeled block or loop
Labels
A label is the @
character followed by an identifier, e.g., @label
. Labels
can be applied to blocks or loops to allow controlled execution flow in nested
code.
var total = 0;
@outer: for x in [1, 2, 3] {
for y in [1, 2, 3] {
if x == y {
continue @outer;
};
# Only executed for [(2, 1), (3,1), (3, 2)]
total = total + x * y
};
};
let 11 = total;
Continue
The continue
expression takes an optional label. If no label is provided,
execution jumps to the location to begin the next iteration of the loop
containing the code. If no loop is found, a compilation error will occur.
If a label is provided, the label must belong to a loop. If not, a compilation error will occur.
Break
The break
expression takes an optional label and an optional expression. If no
label is provided, execution jumps to the location after the current loop exits.
If no loop is found, a compilation error will occur.
If a label is provided, execution will jump to the the next instruction after
the block or loop identified by that label. The result of the block or loop will
be the optional expression or nil
.
Why Muse over ...?
Muse's main distinguishing factor is that it attempts to provide a safe way to execute untrusted code, including code that may never terminate.
All languages have different design goals, performance, and developer experiences. In the end, there are many viable options for embedded languages. The choice of what language to embed is a highly personal decision.
Why use Muse over WASM?
Overall, WASM is a very tempting option for safely executing untrusted code within another application. Embedding WASM allows users to run arbitrary code in isolated environments incredibly efficiently and safely. Many runtimes also support budgeted execution to allow developers to defend against inefficient code or infinite loops. It used to be a fair amount of work to set up a plugin system using WASM, but Extism makes it incredibly easy today.
The downsides might be:
- Large number of dependencies. Let's face it, build times can sometimes be an issue. Muse has limited dependencies, and due to being a very focused implementation, it means Muse will always be less code than embedding a WASM runtime.
- Serialization Overhead. With Muse, your Rust types can be used directly within the scripts with no serialization overhead. With WASM, because memory isn't guaranteed to be compatible, serialization is often used when passing data between native code and WASM.
- Split documentation. With every language that your plugin system supports, you might feel the need to provide documentation for those languages. By focusing on a single embedded language, the end-user documentation may be less work to maintain.
Ultimately if the allure of having users be able to write code in the language they are most familiar with, WASM is a great option.