Introduction
Ad Astra is a configurable scripting language designed primarily for embedded use in Rust applications.
The language features an easy-to-learn, minimalistic syntax that should feel familiar to users of JavaScript or Python. Developers can expose parts of their host Rust crate APIs — such as functions, types, type methods, and operators on types — to the script environment. These APIs collectively form a domain-specific customization of Ad Astra, enabling the end user to interact with the Rust application at runtime in a fully dynamic way.
Built-in Language Server
Usability is one of the key design goals of Ad Astra.
Ad Astra offers a full-featured LSP (Language Server Protocol) server that supports a wide range of editor features, such as code completions, type hints, symbol references, and more.
Through the editor environment, users can explore the exported domain-specific APIs. The user-facing documentation for these APIs mirrors the RustDoc documentation of the original exported Rust APIs. Overall, the language server aims to provide the script user with an experience on par with RustAnalyzer.
You can try the language server features in the Ad Astra Playground, a static web application with the LSP server running in a local web worker.
Exporting
Rust programmers can export Rust APIs directly to the script environment by annotating the corresponding APIs with the Export attribute macro.
In most cases, developers don't need to maintain an extra abstraction layer between the Rust static APIs and the fully dynamic script runtime. The export system automatically performs all necessary Rust code introspections and exporting. However, the Ad Astra crate also provides low-level exporting components for fine-grained export edge cases.
#[export]
pub fn deg(degrees: f64) -> f64 {
PI * degrees / 180.0
}
#[export]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vector {
pub x: f64,
pub y: f64,
}
#[export]
impl Add for Vector {
type Output = Self;
fn add(mut self, rhs: Self) -> Self::Output {
self.x += rhs.x;
self.y += rhs.y;
self
}
}
#[export]
impl Vector {
pub fn radius(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
}
Book
The User Guide sections of this book describe the base language syntax and semantics.
The Developer Guide is a tutorial that walks you through the Ad Astra exporting system, as well as the compiler and language server setup steps.
Quick Links
Copyright
This work is proprietary software with source-available code.
To copy, use, distribute, or contribute to this work, you must agree to the terms and conditions of the General License Agreement.
For an explanation of the licensing terms, see the F.A.Q.
Copyright (c) 2024 Ilya Lakhin (Илья Александрович Лахин). All rights reserved.
Playground
- Language Client
- Language Server
- Example File
Shortcuts:
- Pressing Ctrl+S in the editor formats the code.
- Pressing Ctrl+Space opens the code completion menu.
- Pressing Ctrl+Click on a variable or field jumps to its definition.
- Press F2 on a variable or field to rename it.
Exported Rust Module
The source code above can use exported constructions from Rust's
algebra.rs
module via the script's use algebra;
import statement.
use std::{
f64::consts::PI,
fmt::{Display, Formatter},
ops::{Add, Mul, Neg},
};
use ad_astra::export;
/// An example package with basic 2D coordinate system transformation features.
#[export(package)]
#[derive(Default)]
struct Package;
/// Converts degrees to radians.
#[export]
pub fn deg(degrees: f64) -> f64 {
PI * degrees / 180.0
}
/// Converts radians to degrees.
#[export]
pub fn rad(radians: f64) -> f64 {
180.0 * radians / PI
}
/// Rounds a floating-point number up to the nearest integer.
#[export]
pub fn round(value: f64) -> i64 {
value.round() as i64
}
/// A 2D vector.
#[export]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vector {
/// The x-coordinate of the vector.
pub x: f64,
/// The y-coordinate of the vector.
pub y: f64,
}
#[export]
impl Neg for Vector {
type Output = Self;
fn neg(mut self) -> Self::Output {
self.x = -self.x;
self.y = -self.y;
self
}
}
#[export]
impl Add for Vector {
type Output = Self;
fn add(mut self, rhs: Self) -> Self::Output {
self.x += rhs.x;
self.y += rhs.y;
self
}
}
#[export]
impl Display for Vector {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("vec({}, {})", self.x, self.y))
}
}
#[export]
impl Vector {
/// Constructs a new 2D vector.
#[export(name "vec")]
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
/// Returns the magnitude (or length) of the vector.
pub fn radius(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
/// Returns the angle of the vector in the 2D coordinate system.
pub fn angle(&self) -> f64 {
self.y.atan2(self.x)
}
/// Normalizes this vector by setting its magnitude to 1 while preserving
/// the original angle.
pub fn normalize(&mut self) -> &mut Self {
let r = self.radius();
self.x /= r;
self.y /= r;
self
}
/// Transforms this vector using the provided transformation matrix.
pub fn transform(&mut self, matrix: &Matrix) -> &mut Self {
let x = matrix.x.x * self.x + matrix.x.y * self.y;
let y = matrix.y.x * self.x + matrix.y.y * self.y;
self.x = x;
self.y = y;
self
}
}
/// A 2x2 transformation matrix.
#[export]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Matrix {
x: Vector,
y: Vector,
}
#[export]
impl Mul for Matrix {
type Output = Self;
fn mul(self, rhs: Matrix) -> Self::Output {
Self {
x: Vector {
x: self.x.x * rhs.x.x + self.x.y * rhs.y.x,
y: self.x.x * rhs.x.y + self.x.y * rhs.y.y,
},
y: Vector {
x: self.y.x * rhs.x.x + self.y.y * rhs.y.x,
y: self.y.x * rhs.x.y + self.y.y * rhs.y.y,
},
}
}
}
#[export]
impl Matrix {
/// Creates a 2x2 transformation matrix that rotates the coordinate system
/// by the specified angle in radians.
pub fn rotation(angle: f64) -> Self {
let (sin, cos) = angle.sin_cos();
Self {
x: Vector { x: cos, y: -sin },
y: Vector { x: sin, y: cos },
}
}
/// Computes the determinant of this matrix.
pub fn det(&self) -> f64 {
self.x.x * self.y.y - self.x.y * self.y.x
}
/// Constructs a new matrix that is the inverse of this one.
pub fn invert(&self) -> Self {
let det = self.det();
Self {
x: Vector {
x: self.y.y / det,
y: -self.x.y / det,
},
y: Vector {
x: -self.y.x / det,
y: self.x.x / det,
},
}
}
}
Syntax Overview
Ad Astra is a dynamically typed imperative scripting language with elements of functional and concatenative programming paradigms.
The base semantics of the language is similar to JavaScript: a typical script consists of anonymous functions and structural objects that form the program's design.
let my_object = struct {
field: 10,
method: fn(param) {
self.field += param;
},
};
my_object.method(20);
my_object.field == 30;
Visually, the language attempts to mimic Rust's syntax: variables are introduced
with the let
keyword, objects with the struct
keyword, functions with the
fn
keyword, and so on.
In general, Ad Astra is a dynamically typed language, meaning that variable and expression types are inferred during script evaluation. However, there are certain static restrictions imposed on language constructs. For instance, you cannot change the type of a variable once it has been assigned a value, nor can you invoke a function with an arbitrary number of arguments.
let x = 10;
x = 20; // But you cannot assign a string literal to `x`.
These restrictions make the code architecture less ambiguous than, for example, JavaScript or Python programs. At the same time, they make static source code analysis more predictable within local evaluation contexts.
The primary source of program polymorphism is user-defined functions. A function introduced by the user does not impose restrictions on input parameter types or the output type. Essentially, all script-defined functions are polymorphic.
let concat_two_args = fn(a, b) {
return [a, b];
};
concat_two_args(10, 20); // Creates an array of numbers: [10, 20].
concat_two_args("hello ", "world"); // Creates a new string: "hello world".
In contrast, the APIs exported from Rust have well-defined typed signatures.
For example, if you have a Rust function fn deg_to_rad(x: f64) -> f64
, you
cannot call this function in scripts by providing a struct
argument. Doing so
would result in a runtime error, and the static analyzer will detect such misuse
in most cases.
Finally, the script user cannot introduce new types or operators on the types. All types and the operations on them (including type methods) are exported from Rust to the script, defining the domain-specific environment for the script code.
Control Flow
The script code is the body of an implicit function with zero parameters, which serves as the entry point of the script.
The body of a function (including the entry-point code) consists of statements that are executed sequentially, except for control flow statements such as loops and conditionals.
// Injects additional APIs from the sub-package "algebra"
// into the current namespace.
use algebra;
let x = 10; // Variable declaration.
foo(x + 20); // Expression statement.
// Simple conditional statement.
if x == 10 {
do_something();
}
// Complex conditional statement.
match x {
10 => {},
20 => {},
else => {},
}
// Infinite loop statement.
loop {
x += 1;
if x > 10 {
break; // Breaks the loop.
}
}
// For loop that iterates through the numeric range from 10 to 19 inclusive.
for i in 10..20 {
dbg(i);
}
// Nested statement block.
{
let inner_var;
func_1();
func_2();
}
// Returning from a function (from the script's main function in this case).
return "end";
Variables
let x = 10;
let y;
y = 10;
Variable introduction starts with the let
keyword, followed by the variable
name, and ends with a semicolon (;
).
The optional = 10
part initializes the variable immediately with the provided
expression. You can delay a variable's initialization (as in the case of the y
variable), but you cannot use an uninitialized variable until it is fully
initialized.
let x;
if something() {
x = 10;
// The variable `x` is considered fully initialized here, and you can use it.
}
// However, outside of the condition's block, the variable `x` might be
// uninitialized.
let y;
match something() {
true => { y = 10; }
false => { y = 20; }
}
// The variable `y` is fully initialized here because the `match`
// statement covers all possible control-flow branches.
You can use any Ad Astra identifier as a variable name, which is a sequence of alphanumeric and "_" ASCII characters. Note that Ad Astra currently does not support arbitrary Unicode identifiers.
Variables allow users to introduce new functions and structure instances in the code. In Ad Astra, structures and functions are anonymous, and by assigning them to variables, users create "named" functions and structures.
let sum = fn(x, y) {
return x + y;
};
let st = struct { field: 10 };
st.field = sum(10, 20);
Identifier Shadowing
A variable introduction statement shadows any identifier with the same name that was previously introduced in the scope.
let x = 10;
let x = 20; // Shadows the previously introduced `x`.
{
let x = 30; // Shadows the previous `x`, but only within the block context.
x == 30;
}
x == 20;
Expression Statements
An expression statement is any Ad Astra expression that ends with a semicolon (;
).
(10 + 20) * (30 + foo(40));
Note that, unlike in Rust, Ad Astra statements are not expressions. The following
syntax is not allowed: return match x { 10 => true, else => false};
.
Variable Initialization Statement
The engine interprets the <variable_name> = <expr>;
assignment syntax as a
special expression statement that initializes the variable if it is not yet
initialized. Otherwise, it treats this expression as a standard assignment
operation.
Note that you cannot initialize uninitialized variables in any other way. Inner assignment expressions will also be interpreted as assignment operations.
let x;
// This will not initialize variable x:
// foo(x = 10);
// But this expression is an initialization expression.
x = 10;
Nested Blocks
You can introduce nested code blocks, for example, if you want to limit the scope of a variable.
let x = 10;
{
let y = 30;
x = y;
}
x == 30;
// The variable `y` is inaccessible here.
In contrast to JavaScript, variables introduced within the nearest local namespace context (e.g., a code block) exist only until the end of that context.
Import Statements
If the script environment includes additional packages through which the host
system exports extra APIs (such as Rust functions or constants), the script can
inject these API identifiers into the current namespace scope using the
use <package>;
statement.
use algebra;
// Calls the function "vec" from the imported package "algebra".
let v = vec(0.0, 1.0);
By importing identifiers from a package, they shadow any previously introduced identifiers, similar to variable shadowing.
let vec = 10;
{
use algebra;
vec(0.0, 1.0); // Refers to the "algebra.vec" function.
}
// Outside of the block, the original `vec` variable is no longer shadowed.
vec == 10;
Note that you can refer to package identifiers directly without importing them.
Additionally, you can always refer to the current package using the built-in
crate
identifier, which cannot be shadowed.
let vec = 10;
algebra.vec(0.0, 1.0);
let algebra = 20;
crate.algebra.vec(0.0, 1.0);
// Since the "algebra" package is shadowed by the `algebra` variable, you can
// import this package using the `crate` built-in identifier.
use crate.algebra;
// Refers to the "algebra.vec" function.
vec(0.0, 1.0);
Loop Statements
There are two loop constructs: an unlimited loop and a for-iterator loop.
let i = 1;
// Repeats the code block an unlimited number of times until
// a break statement is encountered.
loop {
if i >= 100 {
break;
}
dbg(i);
i += 1;
}
// Repeats the block for each numeric value in the range,
// or until the loop is explicitly broken.
for i in 1..100 {
dbg(i);
}
The loop statement repeats the code block indefinitely until the program
encounters a break;
statement.
The for-iterator introduces a new integer variable (specified before the in
keyword), which iterates through every numeric value within the range expression
(specified after the in
keyword).
The range expression can be any Ad Astra expression that returns a numeric
range. In the example above, the script will enter a code block with the i
numeric variable iterating from 1 to 99 inclusive. This variable is accessible
only within the body of the for-loop block (and all nested code within that
block).
Breaking and Continuation
Within the body of loop and for-iterator statements, the code can invoke
break;
and continue;
statements.
The break;
statement immediately ends the loop's execution, while the
continue;
statement skips the remaining code in the current iteration and
moves on to the next iteration.
let i = 0;
loop {
if i >= 10 {
break;
}
if i % 2 == 0 {
continue;
}
// Prints 1, 3, 5, 7, 9
dbg(i);
i += 1;
}
Note that loop control statements affect the nearest loop in which the statement is nested. If a loop is nested within another loop, breaking the inner loop will cause the outer loop to continue its execution.
loop {
loop {
break; // Exits the inner loop.
}
break; // Exits the outer loop.
}
Conditional Branching
Ad Astra provides two conditional statements that control the flow of execution
based on a conditional expression: the simple single-branching if
statement
and the multi-branching match
statement.
If Statement
if 20 > 10 {
// Body
}
The if-statement evaluates the provided expression to a boolean value. If the
value is true
, the body block will be executed; otherwise, the block will be
skipped.
In Ad Astra, if statements do not have "else" branches
(e.g., if foo {} else {}
syntax is forbidden). For multi-branching logic,
you should use match statements instead.
Match Statement
In Ad Astra, the multi-branching match statement serves the purpose of "switching" conditional branching, and it comes in two forms: a match statement with a subject and a match statement without a subject.
match subject {
10 => {},
20 => {},
else => {},
}
match {
foo > 10 => {},
bar < 20 => {},
else => {},
}
The body of the match statement (the code enclosed in {...}
curly braces)
consists of match arms. Each arm contains a testing expression specified
before the =>
arrow and an arm body specified after the arrow.
Match arms are separated by ,
commas, with an optional trailing comma. If the
arm's body is a code block, the comma separator can be omitted.
As the body, the user can specify either a code block or an expression, which will be interpreted as a block with a single expression statement.
The script engine executes match arms one by one in the order they are specified.
If the match statement has a subject expression, the engine tests for equality between the subject value and the arm's expression. If the statement does not have a subject, the engine interprets the arm's expression as a boolean.
Once the engine finds the first truthful arm, it executes its body and ends the match statement.
For example, an "if-else" branching could be expressed as follows:
match foo > 10 {
true => dbg("foo is greater than 10"),
false => dbg("foo is less than or equal to 10"),
}
The "switch" branching could be expressed as follows:
match foo {
2 => dbg("foo is equal to 2"),
7 => dbg("foo is equal to 7"),
else => dbg("foo is neither 2 nor 7"),
}
Exhaustiveness
Exhaustiveness means that the conditional branching covers all possible conditions.
The if
statement is never exhaustive unless the conditional expression is a
trivial true
or false
literal, because this statement does not have a
fallback "else" case.
The match
statement can be exhaustive if it covers all possibilities.
For example, if the statement has match arms covering both true
and false
values.
To make the match branching explicitly exhaustive, you can introduce a special
fallback arm: else => {}
(the "else" keyword is a built-in construct).
This fallback arm should be the last one in the list of match arms, and it will be executed as the final option if all previous conditions fail.
Variable Initialization
You can conditionally initialize a variable using an exhaustive match statement.
let x;
match foo {
"bar" => x = 10,
"baz" => x = 20,
else => x = 30,
}
// Variable `x` is fully initialized here.
dbg(x);
Returning Statements
The return;
and return <expr>;
statements immediately stop the current
function's execution and return the provided expression as the function's
result. The variant without an expression returns a nil value from the
function. If the function reaches its end without an explicit return, it returns
a nil value implicitly.
let foo = fn() {
// Returns the value 100 from the foo function.
return 100;
};
let one_hundred = foo();
// Returns the value 200 from the script.
return one_hundred * 2;
Depending on the Ad Astra specialization, the value returned from the script may represent the result of the script's execution.
Expressions
Expressions in Ad Astra are constructs that compute data values.
Expressions are distinct from control-flow statements. Unlike in Rust, Ad Astra statements are not expressions and do not produce any values.
An expression is any combination of atomic operands and operators applied to other expressions:
-
Literals:
-
Integer literals:
100
,200
. -
Float literals:
10.5
,0.1234e-6
. -
Unicode string literals:
"foo"
,"abra cadabra"
. -
Boolean literals:
true
,false
. -
Nil type constructor:
[]
.
-
-
Identifiers:
-
A normal variable, function argument, or exported symbol identifier:
foo
. -
Built-in context variable:
self
.Under
struct
method functions, this variable refers to the struct object instance, but in general, it could refer to any object depending on the Ad Astra specialization that describes the function's calling context. By default (and unless the function is a struct method),self
is a nil value. -
Built-in current package reference:
crate
.This identifier always points to the script package under which the script code is being evaluated. For example, if you have a variable "foo" that shadows a function "foo" from the current package, you can always call the package function using the
crate
identifier:crate.foo()
. -
Built-in maximum constant:
max
.This constant evaluates to the maximum unsigned integer number and is useful for the unbound range construct:
10..max
(all numbers from 10 to "infinity").
-
-
Binary operators such as
<left_operand> <op> <right_operand>
, where the left and right operands are any expressions, and the operator between them is any of the following:-
Assignment operators:
foo = bar
. -
Arithmetic operators:
foo + bar
,foo - bar
,foo * bar
,foo / bar
,foo % bar
. -
Bitwise operators:
foo & bar
,foo | bar
,foo ^ bar
,foo << bar
,foo >> bar
. -
Logical operators:
foo && bar
,foo || bar
. -
Composite assignments:
foo += bar
,foo -= bar
,foo *= bar
,foo /= bar
,foo &= bar
,foo |= bar
,foo ^= bar
,foo <<= bar
,foo >>= bar
,foo %= bar
.These operators usually perform the corresponding binary operation on the operands and then assign the result to the left-hand operand.
Note that
&&=
and||=
are not supported. -
Equality operators:
foo == bar
,foo != bar
. -
Ordering operators:
foo > bar
,foo >= bar
,foo < bar
,foo <= bar
.
-
-
Unary operators:
-
Copy operator:
*<expr>
.This is a built-in operator that creates a clone of the underlying operand. For example,
*"hello world"
creates a copy of a string. -
Nil testing operator:
<expr>?
.Another built-in operator that tests if the operand is a nil value or of a void-like type.
-
Numeric negation:
-<expr>
. -
Logical negation:
!<expr>
.
-
-
Array Constructor:
[a, b, c]
. -
Array Length:
my_array.len
. -
Array Index:
foo[index]
, whereindex
is a numeric unsigned integer value or a range (e.g.,10..20
).In the case of ranges, the operator returns a slice of the
foo
array that spans a sequence of the original array within the specified range. For example,[10, 20, 30, 40][1..3]
evaluates to the array[20, 30]
. -
Range Constructor:
start..end
, wherestart
andend
are any unsigned integer values.The constructed range object specifies indices starting from the
start
value (inclusive) up until theend
value (exclusive). The3..5
range means indices 3 and 4. The end bound must be greater than or equal to the start bound; otherwise, the range is invalid, which will result in runtime errors in most cases. -
Function Invocation:
expression(arg1, arg2, arg3)
.This syntax can be applied to any expression value that implements Invocation. In particular, this operator can be applied to script-defined functions and functions exported from packages. The
arg1
,arg2
, etc., are the argument expressions that will be assigned to the function parameters.In Ad Astra, the number of function parameters is always fixed. You cannot invoke a function with a different number of arguments than the number of parameters in the original function signature.
-
Field Access:
foo.bar
orfoo.3
.The field name could be any valid Ad Astra identifier or an integer literal.
Most data types (usually, most of the custom exported types) support a limited set of predefined fields that can be accessed in scripts. The script engine refers to these as the type's components. Component types are well-defined and can be inferred at compile-time. However, some types (such as the script structure type) support arbitrary field access semantics. In this case, field resolution is fully dynamic.
The field access operator can return a data object of any arbitrary type, and in particular, an invokable function that serves as the type's method.
The majority of the above operators can be overloaded by the host (through script engine specialization), and their meaning may vary depending on the operand types.
For some built-in Ad Astra types, the specification establishes concrete,
canonical meanings and implementations for these operators. For example,
10 + 20
is an addition of two numbers resulting in the number 30, which is
canonical for most programming languages, including Ad Astra.
However, for specific value types, the addition operator may have a domain-specific meaning. For instance, adding one 3D object to another could represent the union of those objects.
Overloadable operators are associated with the type of their first operand:
in the addition 10 + 20
, the operator is invoked on the numeric type (because
10
is a numeric type). In this sense, binary operators, in general, are not
reflexive. The expression 10 + "20"
would attempt to add the number 10 and the
number 20 parsed from a string, but "10" + 20
is illegal because the string
type does not have an addition operator.
Operators Priority
Operators have precedence and, in the case of binary operators, associativity.
Precedence can be altered using parentheses (...)
.
For example, a + b + c
is equivalent to (a + b) + c
.
In general, Ad Astra's operator precedence is similar to the rules in Rust and many other languages with C-like syntax.
Operators | Precedence | Associativity |
---|---|---|
Assignment: a = b , a += b , etc | 1 | Right-to-Left |
Binary disjunction: a || b | 2 | Left-to-Right |
Binary conjunction: a && b | 3 | Left-to-Right |
Equality and ordering: a == b , a > b , etc | 4 | Left-to-Right |
Range operator: 10..20 | 5 | Left-to-Right |
Bitwise disjunction: a | b | 6 | Left-to-Right |
Bitwise exclusive disjunction: a ^ b | 7 | Left-to-Right |
Bitwise conjunction: a & b | 8 | Left-to-Right |
Bitwise shift: a << b and a >> b | 9 | Left-to-Right |
Additive: a + b and a - b | 10 | Left-to-Right |
Multiplicative: a * b , a / b , and a % b | 11 | Left-to-Right |
Unary Left: -a , *a , !a | 12 | Left-to-Right |
Unary Right: a? , a(arg) , a[idx] , a.field | 13 | Left-to-Right |
Atomic operand: ident , crate , self , max | 14 | Left-to-Right |
Operators with a higher precedence number take priority over those with a lower
precedence number: a + b * c
means a + (b * c)
, because multiplicative
precedence is higher than additive.
Associativity indicates the typical order of operand evaluation. In the
expression a = b + c
, the b + c
expression is evaluated before a
.
Arrays
In Ad Astra, every data object is an array, usually an array with just one element.
For example, the expression 100
creates an array with a single numeric value.
Most semantic constructions, operators, and exported functions typically create
singleton arrays (arrays with one element). Singleton arrays are convenient
operands, allowing the script code to apply operations like addition on
singletons: 10 + 20
. However, this operator is inapplicable to multi-element
arrays: [10, 20] + [30, 40]
.
To create a new array, you can use the array constructor: [10, 20, 30]
.
To access a single element of the array, you can use the index operator:
foo[10]
. The index operator also accepts a range value that maps the array to
a slice: foo[10..20]
.
To get the length of the array, you can use the special .len
built-in field:
[10, 20, 30].len == 3
.
Since every data object is an array, this field is available for any data object
regardless of its type: 10.len == 1
.
let my_array = [10, 20, 30, 40];
for i in 0..my_array.len {
// Prints 10, 20, 30, and 40.
dbg(my_array[i]);
}
Mutability
In Ad Astra, arrays are immutable in length; however, the individual elements of an array can be mutated if the corresponding data type supports mutation.
For example, numeric types support mutation, so you can change the elements of an array.
let my_array = [10, 20, 30, 40];
for i in 0..my_array.len {
my_array[i] /= 10;
// Prints 1, 2, 3, and 4.
dbg(my_array[i]);
}
Ad Astra does not provide variable-sized arrays out of the box. Ad Astra arrays are analogous to Rust's fixed-size arrays, which cannot be resized or reallocated.
For vector-like data types with dynamic resizing, the underlying engine specialization may provide corresponding higher-level APIs.
Arrays Concatenation
The array constructor operator [a, b, c]
is an overloadable operator that
typically concatenates the provided arguments into a single array of elements of
the same type.
The implementation of this operator is type-dependent, but the canonical implementation simply constructs a new array from the provided elements:
- If the argument is nil or an empty array, the implementation skips this element.
- Otherwise, the implementation attempts to cast each element of the argument's array into a target type and adds these casted elements to the resulting array.
The expression [10, 20, 30]
creates an array with 10, 20, and 30 numeric
values.
The expression [[10, 20], [], [30]]
creates the same flat array of 10, 20,
and 30 numeric values.
The constructor [[10]]
simply creates the number value 10: [[10]] == 10
.
As a target data type into which each argument will be cast, the canonical implementation uses the first non-nil argument.
The [10, "20"]
constructor creates an array of numbers, attempting to parse
the second argument into a number, while the ["10", 20]
expression creates
the string "1020" because the first non-nil argument is a string. Therefore,
the rest of the arguments will be stringified as well.
Nil Data
Ad Astra has a concept of nil data, a special object that intentionally does not represent any value.
Similar concepts exist in many scripting languages such as JavaScript, Python, nd Lua. However, in Ad Astra, nil data is less severe. For example, script code cannot access a variable that does not exist and receive nil data. Instead, such access would result in a hard compile-time error. Additionally, most built-in APIs usually never accept nil data types as arguments.
One practical case where nil data may appear is when an exported Rust function
returns Option<T>
and this option is None
. Such a possibility should be
clear to the user because the editor displays the original Rust function
signature via the LSP server.
To check if a value is not nil, you can use the built-in foo?
operator.
// fn exported_function() -> Option<usize>
let foo = exported_function();
match foo? {
true => dbg(foo + 10),
false => dbg("The result is nil"),
}
To manually construct a nil data object, you can use an array constructor without arguments.
[]? == false;
In general, it is recommended to avoid using nil data in scripts to prevent possible "null-pointer" bugs, but this decision ultimately depends on the script's design.
Strings
The "hello world"
string literal creates a string object.
Ad Astra strings are immutable arrays of unsigned bytes that encode Unicode
strings. These values are compatible with Rust's immutable str
type.
Since strings are arrays of bytes, script code can concatenate them using the array constructor.
["hello", " ", "world"] == "hello world";
The script engine interprets strings slightly differently than normal byte arrays, considering that these arrays encode text data:
-
The array constructor attempts to stringify each argument into a string during the argument type casting. This feature is particularly useful for constructing formatted strings.
let x = 10; // Prints "The value of x is 10". dbg(["The value of x is ", x]);
-
The string index operator indexes by the string's Unicode characters rather than the underlying bytes.
"hello world"[1] == "e"; "hello world"[1..7] == "ello w";
Numbers
The script code creates numeric objects using integral literals such as
1234567
, and floating-point literals such as 123.456
, 123.456e2
, or
123e-3
.
Numeric Operations
For numeric types, the following operators are available:
-
Arithmetic operations:
a + b
,a - b
,a * b
,a / b
. -
Bitwise operations (for integer numbers only):
a & b
,a | b
,a ^ b
,a << b
,a >> b
. -
Remainder of division (for integer numbers only):
a % b
. -
Assignment operator:
a = b
. -
Composite assignment of any of the above:
a += b
,a &= b
, etc. -
Equality and ordering:
a == b
,a > b
,a >= b
,a < b
,a <= b
. -
Numeric negation:
-a
.
Numbers Conversion
The underlying type of a numeric value is platform-specific and can be any
Rust primitive numeric type such as usize
, isize
, f64
, i32
, etc.
The script engine selects the best type that suits the needs of the underlying
value representation. In scripts, numerics are represented as the generalized
number
type, and the engine performs automatic number type conversions as
needed.
In general, Ad Astra numbers behave similarly to those in many other scripting languages that do not distinguish between numeric types.
For this reason, script code can perform numeric operations on numbers of different types transparently most of the time.
10 + 4.5 == 14;
10.3 + 2 == 12.3;
[18, 3.6, -9]; // Creates an array of floats.
When applying numeric binary operators, the script attempts to cast the right-hand operand to the type of the left-hand operand.
For this reason, the script will not be able to perform this subtraction:
10 - 30
, because the left-hand side is an unsigned integer. To make it signed,
you can prefix the literal with the +
sign: +10 - 30
.
In general, to enforce casting to a desired type, you can start the numeric operation with a numeric literal of that type.
10 + 4.5 == 14;
0.0 + 10 + 4.5 == 14.5;
+10 - 30 == -20;
When the script code calls an exported Rust function with a parameter of a specific numeric type, the script engine performs numeric conversion automatically whenever possible.
// fn foo(usize);
foo(10);
foo(10.5); // Passes 10 by truncating the fractional part.
foo(-20); // Leads to a runtime error because -20 cannot be converted to usize.
Booleans
Boolean values are constructed using the true
and false
keywords, as well as
through operators and methods that return booleans. For example, 20 > 10
returns a boolean true
value.
For boolean values, the logical conjunction a && b
, logical disjunction
a || b
, and logical negation !a
operators are available.
Logical conjunction has a higher precedence than disjunction: a && b || c && d
means (a && b) || (c && d)
.
Boolean values are primarily useful for branching statements that direct code execution based on the truthfulness of a condition.
Ranges
Range objects can be instantiated using the from..to
syntax. Their primary
purpose is to specify a range of unsigned integer numbers, such as indices for
array slices and iteration ranges in for statements.
let array = [10, 20, 30, 40, 50];
array[1..3] == [20, 30];
for i in 7..12 {
dbg(i); // Prints: 7, 8, 9, 10, and 11.
}
The from
and to
parts are any expressions that can be evaluated to unsigned
integer numbers. The from
value specifies the range's lower bound (inclusive),
and the to
value specifies the upper bound (exclusive).
The upper bound should be greater than or equal to the lower bound. Otherwise, the range is invalid, which will lead to runtime errors in most cases.
To construct a range with an "unlimited" upper bound, you can use the max
built-in constant, which evaluates to the maximum unsigned integer number
available on the current platform: 50..max
.
Functions
In Ad Astra, a function is any object that supports the invocation operator:
foo(10, "bar")
.
Typically, these objects include script-defined functions, struct methods, and functions or methods exported by the host system (via Rust).
Script-Defined Functions
let func = fn(a, callback) {
return a + callback(2);
};
func(10, fn(arg) arg * 5) == 20;
Script-defined functions are anonymous first-order objects that are usually assigned to variables, struct fields, or passed to other functions as callbacks.
Ad Astra does not impose restrictions on input function parameter types or the output result data type, but the number of arguments must match the number of the function's formal parameters.
There are two forms of script function syntax:
- A multi-line function with a block of code as its body:
fn() {}
. - A one-line function:
fn() expr
.
The one-line function is syntactic sugar for the multi-line function that
evaluates the provided expression and returns its value: fn() { return expr; }
.
By default, a multi-line script-defined function returns nil data unless the
function's body explicitly returns a value (via the return expr;
statement).
Each Ad Astra script-defined function is an independent unit of execution. When the script passes a script function as a callback to an exported Rust function, the host system can evaluate this function in place or independently from the original script execution.
In particular, Ad Astra specializations can provide multi-threaded script execution capabilities through this mechanism.
The host system can also transfer a script function defined in one script module into another script module, thereby enabling multi-module scripting environments.
Function Parameters
Function parameters are the variables associated with the values provided to the function as arguments during the function invocation.
These parameter variables are always considered initialized. As a result, the script cannot pass an uninitialized variable into a function during its invocation.
Closures
A script-defined function can refer to any identifier available in the namespace where the function was declared.
These references remain valid even if the referred variable outlives the function. In such cases, the function continues to refer to the variable's data object for the duration of the function's lifetime.
let outer_func = fn() {
let closure = 10;
let func = fn(arg) closure + arg;
func(20) == 30;
closure *= 5;
func(20) == 70;
return func;
};
let inner_func = outer_func();
inner_func(30) == 80;
Structures
Ad Astra supports a built-in syntax for creating structural data objects.
Informally, Ad Astra structures are key-value data objects that resemble JavaScript objects, Lua tables, or Rust BTreeMaps. They serve the purpose of object-oriented organization in script code design.
let my_object = struct {
field_1: 100,
field_2: 200,
some_method: fn(a, b) {
self.field_2 = self.field_1 * (a + b);
},
};
my_object.some_method(3, 4);
my_object.field_2 == 700;
A structure object is constructed using the struct
keyword followed by a
definition body enclosed in {...}
braces. The body consists of key-value
entries separated by commas, with an optional trailing comma.
The key of an entry can be any valid Ad Astra identifier or an unsigned integer. The value of an entry can be any expression.
Structure values can be accessed using the field access operator:
my_object.field_2
.
Similar to script functions, structures are anonymous objects that are typically assigned to variables or passed directly to other expressions.
Fields Management
The script code can add new structure entries by assigning values to new object fields.
let my_object = struct {
field_1: 10,
};
my_object.field_2 = 20;
my_object.field_3 = fn() {};
my_object.field_1? == true;
my_object.field_2? == true;
my_object.field_3? == true;
my_object.field_4? == false;
The existence of a structure entry can be tested using the ?
nil-test operator
on the structure field: foo.bar?
.
Ad Astra does not provide a built-in way to remove entries from structures, but such a feature could be implemented via exported functions.
Structure Methods
A method of a structure is an entry where the value is a script function.
Inside the method implementation, you can use the built-in special self
variable, which refers to the structure instance.
let my_object = struct {
field: 10,
method_1: fn() self.field * 3,
};
my_object.method_2 = fn() self.field * 4;
my_object.field = 100;
my_object.method_1() == 300;
my_object.method_2() == 400;
Mutability
In Ad Astra, all data is always passed by reference to heap-allocated memory.
The mutability of an object of a particular type is determined by the set of implemented operators and methods that collectively shape the type's interface. This interface may provide full or partial capabilities for mutating the referred data.
For example, Ad Astra's built-in numbers and booleans are inherently mutable objects, but strings and functions are fully immutable.
Assignment Operator
The assignment operator a = b
is a standard binary operator that may or may
not be implemented for a given type.
The purpose of this operator is to replace the data referred to by the left operand with the data referred to by the right operand.
Most built-in and exported types usually implement this operator, but there are exceptions.
For example, all numeric types implement assignment, but the script function type does not. Therefore, script code can reassign numbers but cannot reassign functions.
let x = 10;
x = 20;
let func = fn(a, b) a + b;
// Assignment to function is forbidden.
// func = fn(a, b) a * b;
Variables are Immutable
Formally, all Ad Astra variables are immutable named slots that store references to data objects.
Once a variable is initialized with a value, it cannot be reassigned. All
subsequent assignments will be interpreted by the engine as a call to the
binary =
operator on the type.
let x;
x = 10; // Initializes the variable with the value.
x = 20; // Calls the binary assignment operator: "=(x, 20)".
Built-In Types Mutability
Type | Assignment | Mutability |
---|---|---|
All number types | Implemented | Fully mutable. |
Boolean bool type | Implemented | Fully mutable. |
String str type | Unimplemented | Not mutable. |
Range range type | Unimplemented | Not mutable. |
Function types | Unimplemented | Not mutable. |
Structure struct type | Unimplemented | Partially mutable. New fields can be added. |
Non-singleton arrays | Unimplemented | Individual elements of the array may be mutable. |
Note that non-built-in exported types usually implement the assignment operator and are typically inherently mutable objects.
Boxing
The built-in semantics of Ad Astra covers only the base use cases, assuming that growable arrays, strings, and other immutable constructions do not require data mutation out of the box.
Engine specializations may expose additional APIs that allow the script user to mutate some or all data types, depending on the specialization domain.
For example, a concrete specialization might implement mutable string builders, vectors, or even general mutable boxing objects.
let sb = string_builder();
sb.push("hello");
sb.push(" ");
sb.push("world");
sb.build() == "hello world";
Type System
All Ad Astra types, including built-in and custom exported types, are essentially the types of the host system (Rust types).
Script users cannot define new types within scripts. Instead, they must be provided by the language specialization in Rust. The system of exported Rust types defines the structure of the script domain.
The set of all types known to the script engine is global. While types cannot be referred to directly in scripts, the static analyzer and interpreter are aware of the types associated with manageable script data objects.
Arrays
Arrays are a fundamental aspect of the type system's semantics. In Ad Astra, arrays don't have a dedicated type; rather, every data object in a script is inherently an array.
For example, both 305
and [305]
are singleton arrays. Typically, most script
objects are singleton arrays, which are arrays with just one element.
Ad Astra arrays are flat memory allocations with a fixed number of contiguous elements of the same type.
Arrays are flat in the sense that script code cannot express nested arrays
without boxing: [1, 2, 3, 4]
and [1, [2, 3], 4]
are considered equivalent
data entities.
For simplicity, the static script code analyzer does not distinguish between singleton arrays and arrays with more than one element, assuming that the length of the array is semantically transparent.
// The analyzer assumes that all of the following variables have a `bool`
// type regardless of the array length.
let x = true;
let y = [false];
let z = [true, false, true];
Strings
Ad Astra strings are arrays of unsigned bytes that encode UTF-8 sequences.
The script engine manages strings slightly differently from normal script
arrays. Specifically, the engine infers this type as the built-in str
type
rather than as a number
type.
Nil Type
Another special type is the nil
type. This type does not have instances that
point to any memory allocations.
In scripts, a Nil object can be constructed as an array without elements: []
.
Additionally, script-defined functions that do not return any data have a nil
return type. An exported function, method, or operator that returns Rust's ()
unit type on behalf of the script returns a Nil object.
To check if a data object is not Nil, the script code uses the built-in ?
unary operator.
10? == true; // `10` is not nil.
[]? == false;
let func = fn() {};
func()? == false;
let x = 10;
(x += 5)? == false; // The result of the assignment operator is nil.
Polymorphism and Type Casting
Ad Astra types are unique, monomorphic Rust types.
In general, data objects of distinct types are incompatible. If an exported API function, method, or operator expects a data object of a specific type, the script must provide an argument of that expected type.
Depending on the domain-specific specialization, some data types may offer automatic conversion between objects of distinct types.
For example, the built-in numeric objects support automatic conversion, allowing script code to pass an integer to a function that expects a floating-point number.
Type Families
A type family is a set of types that are semantically related.
They are designed for the convenience of script users. For instance, all
built-in numeric types (e.g., usize
, f32
, i64
, u8
) belong to a single
number
type family.
The analyzer refers to them by the family name rather than by their specific type names, and it typically does not produce a warning if the script code passes an object of one type to a function that expects another type, as long as both types belong to the same family.
Memory Model
Data objects in scripts are always passed by reference to a heap memory allocation.
This includes primitive types as well. For instance, if the script code
introduces a boolean value using the true
keyword, the result of this
expression is a reference to the heap where the boolean data object is
allocated.
The script engine maintains a counter for the active references to script data, ensuring that the object's data allocation persists until the last reference is released.
let x = 500; // Variable `x` contains a reference to the numeric object "500".
x /= 10; // `x` is passed by reference.
dbg(x); // `x` is passed by reference too.
// The end of the `x` lifetime.
dbg("Done");
Function closures prolong data lifetimes:
let func;
{
let x = 5;
// The function captures the data referred to by `x`.
func = fn() { return x; };
// The lifetime of the variable `x` ends here,
// but the data it refers to remains alive.
}
dbg(func()); // Prints "5".
// The lifetime of the function ends here, along with its closure
// to the numeric object "5".
Data Projections
Script code can create a projection of one referential data object to a subset of its memory.
For example, when script code refers to an array by index or range, the interpreter does not create a copy of the element(s). Instead, it returns a reference to the array slice.
let array = [10, 20, 30];
// The `array[1]` expression returns a reference to the second element
// of the array.
let second = array[1];
// Mutates an element of the original array.
second = 200;
// Prints "200".
dbg(array[1]);
If this behavior is not desirable, the script code can copy the referred data
using the *foo
cloning unary operator.
let array = [10, 20, 30];
let second_copy = *(array[1]);
second_copy = 200;
dbg(array[1]); // Prints "20".
Dereferencing
All data operations requested from scripts are eventually performed by the host system (Rust code).
When a script sums two numbers, the operation is executed by the Rust function
that implements the a + b
operator, taking both operands by value.
Passing by value means transferring the data allocation from the script memory to the host system (or implicitly cloning the data if there are multiple active script references to it).
In general, the host code can access script data immutably, mutably, or by taking the data by value (which may require immutable access for cloning).
The type of data access is usually indicated by the corresponding exported
function signature. For example, the host function
fn foo(a: &usize, b: &mut bool)
accesses the first argument immutably and the
second argument mutably.
Script code does not need to manually dereference provided arguments; data dereferencing is handled automatically by the script engine.
However, this implicit dereferencing must comply with Rust's general rules of exclusive access:
- There can be as many simultaneous active immutable dereferences of the same data allocation as needed.
- But if the data is dereferenced mutably, other simultaneous mutable or immutable dereferences are forbidden.
Data dereferencing is managed by the script engine, and failure to comply with these rules results in runtime errors.
Such errors are rare because, typically, data dereferencing is localized.
API Overview
The Ad Astra project aims to develop an embedded scripting language infrastructure integrated into end applications.
Typically, this infrastructure includes the following components:
- A script compiler and interpreter.
- A static code analyzer that verifies the syntax and semantic consistency of script source code, printing possible diagnostic errors and warnings to the terminal (or another output).
- Language extensions for code editors to assist users during script development.
- A source code formatter program.
The Ad Astra crate provides the necessary APIs to implement these components for a custom domain-specific scripting language based on the Ad Astra language.
The examples directory in the Ad Astra repository contains sample setups.
This tutorial will guide you through the most common API functions of the crate. For in-depth learning, refer to the API Documentation.
Language Customization
To extend the base language, you can export Rust APIs such as module types, functions, methods, operators, etc., into the script environment using the #[export] attribute macro.
#[export]
pub fn round(value: f64) -> i64 {
value.round() as i64
}
#[export]
impl Matrix {
pub fn rotation(angle: f64) -> Self {
let (sin, cos) = angle.sin_cos();
Self {
x: Vector { x: cos, y: -sin },
y: Vector { x: sin, y: cos },
}
}
}
The underlying Rust items will be introspected by the macro and exposed in the script, fulfilling the script's domain-specific environment.
The #[export]
macro has certain configuration options that will be explained
in the tutorial, but typically, exporting works without extra effort.
The Algebra Example demonstrates a typical use of the export macro.
Analyzer and Interpreter
To implement the analyzer for a script module, you instantiate a ScriptModule object into which you load the source text of the script.
For example, you can load the text from disk:
let text = read_to_string(&cli.path).expect("Script file read error.");
let module = ScriptModule::new(Package::meta(), text);
After creating the script module, you can query this object for diagnostic errors and warnings and print them to the terminal.
let handle = TriggerHandle::new();
let read_guard = module.read(&handle, 1).expect("Module read error.");
let diagnostics = read_guard.diagnostics(1).expect("Module analysis error.");
if !diagnostics.is_empty() {
// Prints all errors and warnings, if any.
println!("{}", diagnostics.highlight(&read_guard.text(), !0));
}
If there are no errors, you can compile the script module into assembly and run this assembly in the Ad Astra Virtual Machine.
let assembly = read_guard.compile().expect("Script compilation error.");
match script_fn.run() {
Ok(_) => println!("Script execution finished."),
// Otherwise, prints the runtime error.
Err(error) => println!(
"Script execution failure:\n{}",
error.display(&read_guard.text()),
),
}
Depending on your implementation goals, you can continuously watch the source code file for changes and repeat the above steps to provide continuous script execution.
The Runner Example demonstrates this kind of setup.
Code Editor Extension
As a separate program, you can configure and run a language server that interacts with the code editor through the LSP protocol. This server assists the script user in the editor with code completions, type hints, identifier references, and many other features useful for live code development.
fn main() {
LspServer::startup(
LspServerConfig::new(),
LspLoggerConfig::new(),
LspTransportConfig::Stdio,
Package::meta(),
);
}
The LSP Server Example demonstrates a language server setup.
To implement the code editor extension, you need to create an LSP client as a plugin for the code editor. This implementation is editor-specific, so you should consult the documentation of the particular editor you are targeting.
The LSP Client Example is a sample LSP client setup for VS Code.
Code Formatter
Additionally, you can set up a separate Rust program that formats the source text of the Ad Astra script into a canonical form.
The ad_astra::format::format_script_text function takes a string of the source code, formats it, and returns the formatted version of the text.
This setup is optional, as the LSP server offers built-in formatting capabilities.
Web-Assembly Build
The Ad Astra crate is WASM-compatible. Specifically, you can implement a special setup of the script language infrastructure, including the language's runner and code editor, that works entirely within a web browser without requiring a separate web server.
The WebAssembly Example
demonstrates a setup of the script language runner and the LSP server compatible
with the wasm32
target. In this setup, the server-side operates in the
browser's web worker, while the client side is a customized Monaco editor that
interacts with the local LSP server running in the web worker.
This example is also available in the Ad Astra Playground.
Exporting
To export semantics from Rust to a script, you would use the #[export]
attribute macro.
This macro automatically introspects the underlying Rust item, making it available in the script environment.
Typically, you would export Rust crate functions, statics, constants, struct
types, and their impl
blocks, including the implemented operators.
Export Macro Anatomy
The macro should be applied to the Rust item.
For example, this application is allowed:
#[export]
impl Foo {
pub fn bar(&self) {}
}
But the following export is forbidden because the implementation method itself is not a Rust item:
// Missing `#[export]` annotation on the impl block.
impl Foo {
#[export]
pub fn bar(&self) {}
}
The export
attribute may appear multiple times inside the introspected item and
on the same Rust construct to specify more export details.
#[export]
impl Foo {
// Private methods are not exported by default.
// By annotating this method with an additional `#[export]` attribute,
// you enforce the method's export.
#[export]
fn bar(&self) {}
}
Script Packages
The script package is the only structure required for exporting from the Rust crate into the script environment.
#[export(package)]
// The script package must implement the Default trait.
#[derive(Default)]
struct Package;
This object represents the metadata of the current crate, and there should be no more than a single exported script package per crate.
Typically, you place this object in the lib.rs
or main.rs
entry point of the
crate. However, the location is optional. The Ad Astra engine can recognize
exported Rust items regardless of their implementation location and visibility
level.
Other exported crate functions, statics, and constants will be exported on behalf of the crate's script package. Script modules will be evaluated based on the semantics exported into the script package.
For example, if you have an exported function deg
, running the script code on
behalf of this crate's script package will make this function available in the
script.
#[export(package)]
#[derive(Default)]
struct Package;
#[export]
pub fn deg(degrees: f64) -> f64 {
PI * degrees / 180.0
}
let script_module = ScriptModule::new(Package::meta(), "deg(120);");
Package Visibility
The visibility level of the exported package object is up to the implementation.
By making this object public, you allow your crate's API users to run Ad Astra scripts on behalf of this crate directly. This may or may not be desirable depending on the level of encapsulation you want for your crate's API.
Package Dependencies
The dependencies of your script package are the dependencies of your crate
(as specified in the Cargo.toml
file) that also have exported script packages,
regardless of the script package object's visibility.
For instance, in the Exporting Example,
the algebra
crate is a Rust library that exports some Rust APIs into the local
script package of this library.
The Runner Example
is a Rust program that has algebra
as a Cargo dependency and its own
ScriptPackage as well.
When the Runner program runs scripts, the script code imports semantics from the
algebra
package using the import statement.
use algebra;
// `vec` is an exported function in the `algebra` package.
let v = vec(0.0, 1.0);
// Alternatively, the script code can refer to identifiers from dependencies
// by the package names.
let v = algebra.vec(0.0, 1.0);
// The `crate` keyword refers to the current package, where all exported
// semantics reside, including the names of the dependent packages.
let v = crate.algebra.vec(0.0, 1.0);
Functions
To export a crate-global function, you should annotate it with the #[export] attribute macro.
#[export]
fn round(value: f64) -> i64 {
value.round() as i64
}
The parameter types and the return type must be types that are also exported to the script environment1.
By default, eligible types include:
- All Rust primitive numeric types:
isize
,f32
,u8
, etc. - The boolean type:
bool
. - Rust string types:
&str
andString
. - Ranges of unsigned integers:
Range<usize>
,RangeFrom<usize>
, etc. - The unit type
()
. - Tuples of other eligible types:
(bool, String)
. - Slices and fixed-size arrays of eligible types:
&[u32]
,[u32; 6]
, etc. - A box of an eligible type:
Box<(bool, String)>
. - An option of an eligible type:
Option<[u8; 12]>
. - A copy-on-write object of an eligible type with an implicit
'static
lifetime:Cow<str>
. - A result of an eligible type:
Result<usize, Err>
, where the error variant must be a Rust standard error type that isSend + Sync + 'static
. - Certain forms of callback functions.
To make additional types eligible, they should be exported either in this crate or in any dependency crate.
Function Names
All exported crate functions, regardless of their Rust visibility, will be available in scripts within a common flat namespace, under the script package of the crate, using their original Rust function names.
Therefore, their names must be unique within the crate.
To export two independent functions with the same name, you should rename them.
#[export]
fn foo() {}
mod bar {
#[export(name "foo_from_bar")]
fn foo() {}
}
In the script environment, these functions will be exposed as follows:
foo();
foo_from_bar();
// Or:
crate.foo();
crate.foo_from_bar();
References
You can export functions with references in the input positions if the lifetimes of the references are elided. In other words, you can specify references, but you cannot explicitly specify their lifetimes.
#[export]
fn addup(result: &mut usize, arg_1: &usize, arg_2: usize) {
*result += *arg_1 + arg_2;
}
let result = 10;
addup(result, 7, 2);
result == 19;
Callbacks
You can use callback functions as types for the input and output of exported functions, but only in certain forms.
#[export]
fn foo(
arg_1: usize,
arg_2: Box<dyn Fn(usize, usize) -> RuntimeResult<String> + Send + Sync>,
) -> RuntimeResult<String> {
let mut result = arg_2(arg_1, 10)?;
result.push_str(". Some suffix".);
Ok(result)
}
let result = foo(50, fn(x, y) ["Result is: ", x + y]);
result == "Result is: 60. Some suffix.";
The callback function must be a boxed Rust anonymous function (Box<dyn>
) that
accepts up to 7 arguments and returns a value wrapped in the
RuntimeResult
result object.
The function passed into the Rust code is likely to be a script-defined function that will be evaluated by the Ad Astra runtime, which can be error-prone.
The exported function implementation can pass the callback's errors back to the
script environment. Therefore, in the example above, the function foo
also
returns a RuntimeResult
.
To simplify Rust signatures, you can use one of the predefined type aliases for callbacks.
use ad_astra::runtime::ops::Fn2;
// The first two Fn2 generic parameters are the types of the inputs,
// and the last one is the result type.
#[export]
fn foo(arg_1: usize, arg_2: Fn2<usize, usize, String>) -> RuntimeResult<String> {
let mut result = arg_2(arg_1, 10)?;
result.push_str(". Some suffix.");
Ok(result)
}
Or types that can be cast to exported types. For example, the
Option<f32>
type is not an exported type, but the engine is capable of casting
a Rust Option
to the exported f32
type.
Structs
By exporting a Rust struct
type, you register this type in the script engine,
allowing other exported items (e.g., Rust functions) to refer to it.
#[export]
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vector {
// Only the public fields will be exposed in scripts by default.
pub x: f64,
// Enforce private field exposure by annotating the field with `#[export]`.
#[export]
y: f64,
// Read-only fields will be exposed as read-only in scripts.
#[export(readonly)]
pub z: f64,
}
// Referring to Vector as an exported function parameter.
#[export]
fn foo(v: &Vector) {}
The exported Rust structure must be of a type that is Send + Sync + 'static
.
Therefore, you cannot export a structure with non-static lifetime references.
Fields
By default, the macro exposes only the public fields to the scripting
environment. However, you can enforce exposure by annotating the field with the
#[export]
or #[export(include)]
attribute (both are synonyms when applied to
struct fields).
The field type must be one of the following:
- Any Rust numeric type:
f32
,usize
, etc. - The
bool
type. - The unit
()
type. - A range (
Range
) type. - Any exported struct type.
Note that, in contrast to functions, you cannot expose a structure field with a
type like Option<usize>
.
To bypass this limitation, you can prevent public field exposure using the
#[export(exclude)]
annotation and expose the struct field value using
corresponding getters and setters.
#[export]
struct Foo {
#[export(exclude)]
pub bar: Option<usize>,
}
#[export]
impl Foo {
pub fn get_bar(&self) -> &Option<usize> {
self.bar
}
pub fn set_bar(&mut self, bar: Option<usize>) {
self.bar = bar;
}
}
Methods
To export associated implementation members of the exported structure, you
should export the corresponding impl
block of the structure.
#[export]
impl Vector {
#[export(name "vec")]
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
pub fn radius(&self) -> f64 {
(self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
}
pub fn normalize(&mut self) -> &mut Self {
let r = self.radius();
self.x /= r;
self.y /= r;
self.z /= r;
self
}
}
let v = vec(1.0, 3.7, 9.0);
v.normalize();
v.radius() == 1.0;
Similarly to struct fields, the exporting system exposes only public methods by
default. Therefore, if an implementation has a non-public method that you want
to expose, or a public method that you don't want to export, you should
annotate them with the #[export(include)]
and #[export(exclude)]
attributes,
respectively.
There are two types of associated functions:
- Object methods: functions that have
self
,&self
, or&mut self
as a receiver. - Non-methods, such as the
Vector::new
constructor from the example above.
Non-methods will be exported on behalf of the script package, just like normal crate-global functions. Their names must be unique across the exported crate functions namespace.
Usually, you would assign more type-specific names to constructors, such as
renaming the Vector::new
function using the #[export(name = "vec")]
attribute.
In contrast, type methods with a receiver belong to the namespace of the
exported type. Their names must be unique only within the type's namespace.
For example, Vector::radius
does not need renaming even if you export another
type with a method of the same name.
Finally, exported methods may return references with the same lifetime as the
receiver's lifetime. The Vector::normalize
is an example of such a method.
Operators
The export macro recognizes the derives of standard Rust traits such as Clone
,
PartialEq
, Debug
, etc.
By exporting these trait implementations, you enable certain features that the script user can utilize with the type instances.
If the exported structure has a #[derive(...)]
attribute, this attribute must
follow the #[export]
attribute.
#[export]
// These derives will be recognized by the export macro.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vector {
pub x: f64,
pub y: f64,
}
Alternatively, you can export these traits manually by exporting the corresponding implementations.
#[export]
impl Display for Vector {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("vec({}, {})", self.x, self.y))
}
}
Supported Traits
In addition to standard derivable traits, the export system supports the majority of traits from the std::ops module and some other standard Rust traits.
Rust Trait | Script Operators |
---|---|
Clone and/or Copy | Copying: *foo |
Debug and/or Display | Stringification: ["Foo is ", foo] |
PartialEq and/or Eq | Equality: a == b and a != b |
PartialOrd and/or Ord | Comparison: a >= b , a < b , etc. |
Hash | Used implicitly |
Default | Used implicitly |
Add / AddAssign | Addition: a + b / a += b |
Sub / SubAssign | Subtraction: a - b / a -= b |
Mul / MulAssign | Multiplication: a * b / a *= b |
Div / DivAssign | Division: a / b / a /= b |
Not | Logical negation: !a |
Neg | Numeric negation: -a |
BitAnd / BitAndAssign | Bitwise conjunction: a & b / a &= b |
BitOr / BitOrAssign | Bitwise disjunction: a | b / a |= b |
BitXor / BitXorAssign | Bitwise exclusive disjunction: a ^ b / a ^= b |
Shl / ShlAssign | Bitwise left shift: a << b / a <<= b |
Shr / ShrAssign | Bitwise right shift: a >> b / a >>= b |
Rem / RemAssign | Remainder of division: a % b / a %= b |
Note that the assignment script operator (a = b
) is implicitly implemented for
exported Rust structures. For this reason, exporting just the Add
trait
implementation is enough to enable the a += b
script operator.
// Implements + operator between two vectors.
#[export]
impl Add for Vector {
type Output = Self;
fn add(mut self, rhs: Self) -> Self::Output {
self.x += rhs.x;
self.y += rhs.y;
self
}
}
let v = vec(0.0, 1.0) + vec(1.0, 0.5);
// In this case, this is a syntax sugar for using `v + vec(-5.0, 0.0)`,
// where the result is then assigned to the left-hand side.
v += vec(-5.0, 0.0);
v == vec(-4.0, 1.5);
Comments
If the exported item has Rustdoc comments, these comments will be exported as well, and the script user will see them in the code editor.
/// Documentation for the function.
#[export]
pub fn deg(degrees: f64) -> f64 {
PI * degrees / 180.0
}
/// Documentation for the Vector type.
#[export]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vector {
/// Documentation for the `vector.x` field.
pub x: f64,
pub y: f64,
}
#[export]
impl Vector {
/// Documentation for the Vector constructor.
#[export(name = "vec")]
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
Statics and Consts
You can export static
and const
items, along with associated implementation
fields.
The exported values will be available for reading in the script package namespace.
#[export]
static FOO: usize = 10;
#[export(name "BAZZ")]
const BAR: f32 = 30;
FOO == 10;
BAZZ == 30;
// Or:
crate.FOO == 10;
crate.BAZZ == 30;
Advanced Export
In this tutorial, we covered the basic exporting features of Ad Astra that should address most practical use cases. However, the export system supports a broader set of features.
To briefly mention a few:
- The export macro supports polymorphic types with type generics, handled through type monomorphization.
- The export macro also supports traits and trait implementations. While the export system does not export traits themselves, it can export implemented members of traits on specified types.
- Exporting custom Rust types (e.g., enums) through type aliases.
- Implementing type casting through the Downcast and Upcast interfaces.
- More script operators, such as implementing the
a = b
assignment operator on a type or a custom field access resolver. - Exporting functions with dynamic parameters and/or result types.
For further reading, refer to the Export macro documentation and the ad_astra::runtime crate module documentation.
Analysis
You are loading script files into the ScriptModule objects for preliminary static analysis of the script's code semantics and compilation into the Ad Astra Virtual Machine assembly.
let module = ScriptModule::new(
Package::meta(),
"let x = 10; x + 2; dbg(x);",
);
The first argument, Package::meta()
, of the script module constructor is a
reference to the metadata of the script package from which the script will be
analyzed and interpreted.
The second argument is the script's source code, which you can load from a script file.
A script module is a mutable object that does not perform code analysis instantly. Instead, it provides interfaces to incrementally query certain features of the source code, in accordance with recent edits to the source text.
Access Guards
Script modules are designed for script semantic analysis in multi-threaded applications. Even though you can use this interface perfectly well in a single-threaded application, we need to discuss its API a little further.
The design of the ScriptModule is somewhat similar to a read-write lock: it is
an object that can be shared between threads (e.g., by wrapping it in
Arc<ScriptModule>
), and the threads access the underlying data through a
system of read and write guards.
The read guards provide read-only operations on the module content. This kind of access is non-exclusive, allowing as many simultaneous read guards across independent threads as needed, provided there is no active write guard.
The write guard provides both read and write operations. This kind of access is exclusive, meaning that when write access is granted, no other read or write guards can be active.
let handle = TriggerHandle::new();
let read_guard = module.read(&handle, 1).expect("Module read error.");
The ScriptModule::read and ScriptModule::write functions request read and write access, respectively.
Both functions require a handle argument
(TriggerHandle
)
and a guard priority (1
).
The handle is an object that allows you to manually revoke the access grant. For instance, you can clone this object, transfer it to another thread, and trigger it to revoke access.
Typically, you should use a unique instance of the handle for each read and write access request.
The second argument, the priority number, determines the priority of the operations you intend to perform with this guard.
For example, if there are active read guards with a priority of 2, and another thread attempts to request a write guard with a priority of 3, the read guards will be revoked. However, if the write access priority is 1, the request will block the current thread until all read guards with a priority of 2 are released.
Multi-Threaded Applications
In multi-threaded applications, where threads may simultaneously request different types of access operations with distinct priorities, any function returning a ModuleResult may result in a ModuleError::Interrupted error.
This result indicates that the access grant has been revoked. In this event, you should typically drop the access guard (if any), put the current thread on hold for a short amount of time to allow another thread to obtain access with higher priority, and then retry the operation.
loop {
let handle = TriggerHandle::new();
let read_guard = match module.read(&handle, 5) {
Ok(guard) => guard,
Err(ModuleError::Interrupted(_)) => {
sleep(Duration::from_millis(100));
continue;
}
Err(other) => todo!("{other}"),
};
let diagnostics = match read_guard.diagnostics(2) {
Ok(diagnostics) => diagnostics,
Err(ModuleError::Interrupted(_)) => {
sleep(Duration::from_millis(100));
continue;
}
Err(other) => todo!("{other}"),
};
return diagnostics;
}
Single-Threaded Applications
In a single-threaded application, or in a multi-threaded application where each
script module is managed exclusively by a dedicated thread, the situation of
simultaneous access is practically impossible, and the
ModuleError::Interrupted
error should never occur.
Therefore, in practice, you can more confidently unwrap the results of the analysis API functions, which simplifies the overall design.
For instance, the Runner Example application is a single-threaded application that unwraps module results.
let handle = TriggerHandle::new();
let read_guard = module.read(&handle, 5).expect("Module read error.");
let diagnostics = read_guard.diagnostics(2).expect("Module analysis error.");
return diagnostics;
Module Text
The ModuleText object provides access to the original source code of the script.
You can print this object to the terminal, or you can extract a substring of the source code within specified ranges.
let module = ScriptModule::new(
Package::meta(),
"let x = 10;\nlet y = 20;\nlet z = 30;",
);
// Assigns a user-facing name to the script module.
module.rename("Example Module");
let handle = TriggerHandle::new();
let read_guard = module.read(&handle, 1).unwrap();
let module_text = read_guard.text();
// Fetches the second line of the source text.
let second_line = module_text.substring(Position::new(2, 1)..Position::new(3, 1));
assert_eq!(second_line, "let y = 20;\n");
println!("{module_text}");
Displaying the ModuleText
object prints the following snippet:
╭──╢ module [‹doctest›.‹Example Module›] ╟──────────────────────────────────╮
1 │ let x = 10; │
2 │ let y = 20; │
3 │ let z = 30; │
╰───────────────────────────────────────────────────────────────────────────╯
The output snippet's look and feel can be configured via the ModuleText::snippet function. Using this interface, you can set the snippet's header, footer, and annotate specific fragments of the source code.
let mut snippet = module_text.snippet();
snippet.set_caption("Snippet Caption");
snippet.set_summary("Summary line 1.\nSummary line 2.");
snippet.annotate(
Position::new(2, 5)..Position::new(2, 6),
AnnotationPriority::Default,
"Annotation of the variable.",
);
println!("{snippet}");
Prints:
╭──╢ Snippet Caption [‹doctest›.‹Example Module›] ╟─────────────────────────╮
1 │ let x = 10; │
2 │ let y = 20; │
│ ╰╴ Annotation of the variable. │
3 │ let z = 30; │
├───────────────────────────────────────────────────────────────────────────┤
│ Summary line 1. │
│ Summary line 2. │
╰───────────────────────────────────────────────────────────────────────────╯
Diagnostics
The first thing you should do with the script module before compiling and running user script code is analyzing the source code for syntax and semantic issues.
There are two types of issues:
- Diagnostic Errors: These are hard errors. The analyzer is confident that these issues must be fixed in the source code at all costs.
- Diagnostic Warnings: The analyzer has detected a piece of code that is likely problematic, but it is unsure if it would lead to a runtime bug due to the dynamic nature of the Ad Astra script. For example, passing an argument of the wrong type to a function would result in a warning. The analyzer recommends fixing these issues, but it's ultimately up to the user.
Additionally, there are three levels of analysis depth, ordered by their severity:
- Syntax errors.
- Semantic errors and warnings inferred locally.
- Deep semantic analysis of the code for possible diagnostic warnings.
The issues at the lower depth levels are easier to detect and are the most severe.
Before compiling the script, it is recommended to check the script module at least for diagnostic errors at the first two levels of diagnostics.
let module = ScriptModule::new(
Package::meta(),
"let x = 10;\nlet 20;\nlet z = 30;",
);
module.rename("Example Module");
let handle = TriggerHandle::new();
let read_guard = module.read(&handle, 1).unwrap();
for depth in 1..=3 {
let diagnostics = read_guard.diagnostics(depth).unwrap();
// The `!0` argument is the severity mask.
// In this case, we are checking for both diagnostic errors and warnings.
if diagnostics.len(!0) == 0 {
continue;
}
let module_text = read_guard.text();
// Prints diagnostic errors and warnings.
// The `highlight` function returns an annotated snippet.
println!("{}", diagnostics.highlight(&module_text, !0));
return;
}
println!("No issues detected.");
Prints:
╭──╢ diagnostics [‹doctest›.‹Example Module›] ╟─────────────────────────────╮
1 │ let x = 10; │
2 │ let 20; │
│ ╰╴ missing var name in 'let <var> = <expr>;' │
3 │ let z = 30; │
├───────────────────────────────────────────────────────────────────────────┤
│ Errors: 1 │
│ Warnings: 0 │
╰───────────────────────────────────────────────────────────────────────────╯
Editing
The script module is an editable object. You can change the entire source code or just a part of it whenever the user modifies corresponding fragments of the code.
Due to the incremental nature of script analysis, source code edits are efficient operations. The analyzer does not rebuild the entire inner semantic representation of the code with every edit. Instead, it patches its inner structures according to the changes whenever the API user queries corresponding semantic features (e.g., when you query code diagnostics).
To edit the code, you should obtain the script module's write guard.
let module = ScriptModule::new(Package::meta(), "let x = 10;");
module.rename("Example Module");
let handle = TriggerHandle::new();
let mut write_guard = module.write(&handle, 1).unwrap();
// An absolute source code character range.
//
// Alternatively, you can use a line-column range:
// `Position::new(1, 5)..Position::new(1, 6)`.
//
// The `..` range specifies the entire text range:
// `write_guard.edit(.., "let new_variable_name = 10;").unwrap();`
write_guard.edit(4..5, "new_variable_name").unwrap();
let module_text = write_guard.text();
println!("{module_text}");
Prints:
╭──╢ module [‹doctest›.‹Example Module›] ╟──────────────────────────────────╮
1 │ let new_variable_name = 10; │
╰───────────────────────────────────────────────────────────────────────────╯
Advanced Analysis
The script module interface offers additional features for syntax and semantic analysis of the script's source code:
- The completions function returns all possible code completion candidates at the specified cursor point.
- The symbols function allows manual inspection of the source code's syntax constructions and the semantic relations between them.
These and other features provide low-level components for the development of language servers and source code analysis tools for the Ad Astra language from scratch, which are usually unnecessary for typical use case scenarios. For further reading, see the analysis and analysis::symbols API documentation.
Evaluation
To evaluate the script, you need to compile the script module into a ScriptFn object, an Ad Astra Virtual Machine assembly object ready for execution.
let module = ScriptModule::new(Package::meta(), "return \"hello world\";");
let handle = TriggerHandle::new();
let mut read_guard = module.read(&handle, 1).unwrap();
// Compiles the script.
let script_fn = read_guard.compile().unwrap();
// Runs the compiled assembly.
match script_fn.run() {
Ok(result) => {
// Prints: "hello world".
println!("{}", result.stringify(false));
}
Err(error) => {
let module_text = read_guard.text();
println!("{}", error.display(&module_text));
}
}
The compile function compiles the script module. Note that this function is capable of compiling scripts with diagnostic issues, and even if the source code contains syntax errors. Normally, you should not compile and run such scripts, but if you do, the compiler will attempt to produce assembly code that aligns as closely as possible with the original script author's intentions.
To run the script, you call the ScriptFn::run function, which executes the compiled assembly on the current thread until completion and returns a runtime result, which is either a value returned from the script or a RuntimeError.
In the code above, we print the result using the Cell::stringify function, assuming that the object (a string in this case) implements the Display or Debug traits. Alternatively, to get the exact value returned from the script, you can use functions like Cell::take instead.
If the script returns a runtime error, this error indicates a bug that occurred during script evaluation, and you should print this error to the terminal as well:
╭──╢ runtime error [‹doctest›.‹#2›] ╟───────────────────────────────────────────╮
│ ╭╴ receiver origin │
1 │ return "hello world" + 1; │
│ ╰╴ type 'str' does not implement + operator │
├───────────────────────────────────────────────────────────────────────────────┤
│ The object's type that is responsible to perform specified operation does not │
│ implement this operator. │
╰───────────────────────────────────────────────────────────────────────────────╯
Isolation
By default, the ScriptFn::run
function executes the script to completion on
the current thread.
In practice, the script's execution time is unlimited, and the execution process may never end. For example, the script might contain an infinite loop that would never terminate.
To limit the execution process, you can set a thread-local hook that triggers on each Ad Astra assembly command before it is evaluated, allowing the Rust environment to interrupt the script execution manually.
If the script execution is interrupted, the run
function will return the
RuntimeError::Interrupted
variant.
let module = ScriptModule::new(Package::meta(), "loop {}");
let handle = TriggerHandle::new();
let mut read_guard = module.read(&handle, 1).unwrap();
let script_fn = read_guard.compile().unwrap();
let start = Instant::now();
// If the provided hook function returns true, the script runtime continues
// execution. Otherwise, the execution will be interrupted, and the `run`
// function will return an `Interrupted` error.
set_runtime_hook(move |_| start.elapsed().as_secs() <= 5);
match script_fn.run() {
Ok(result) => {
println!("{}", result.stringify(false));
}
Err(RuntimeError::Interrupted { .. }) => {
println!("Script execution lasts too long.");
}
Err(error) => {
let module_text = read_guard.text();
println!("{}", error.display(&module_text));
}
}
Note that although the set_runtime_hook function gives you more control over script evaluation, it slows down the evaluation process because the provided callback is invoked at each step of script execution.
Language Server
The language server is a Rust program that provides semantic analysis metadata for the code editor (the language client) through the LSP protocol.
The LSP language server is an essential component of the code editor's language xtension plugin.
The Ad Astra crate includes a built-in LSP server that you can run using the LspServer::startup function. This function performs all necessary preparations to establish communication with the language client according to the specified configuration and runs the actual server that assists the code editor user.
// General LSP server configuration features.
// Through this object, you can enable or disable certain LSP features.
// The default configuration suits the majority of practical needs.
let server_config = LspServerConfig::new();
// Sets up the server-side and client-side loggers.
//
// The client-side logs are end-user facing messages that will be shown
// in the editor's console (this may vary depending on the editor's user
// interface). Usually, these logs are less verbose and include only general
// messages about the server state.
//
// The server-side logs usually include client-side messages too, but they also
// include additional messages useful for server debugging.
//
// By default, the server uses the STDERR channel (because the STDIO channel may
// be used as the actual LSP communication transport between the server and the
// client). You can manually configure this option to redirect log messages to
// the Unix Syslog or to a custom function.
let logger_config = LspLoggerConfig::new();
// The LSP communication transport. The STDIO channel is the default option
// supported by the majority of code editors that support the LSP protocol.
let transport_config = LspTransportConfig::Stdio;
LspServer::startup(
server_config,
logger_config,
transport_config,
// A script package on behalf of which the files opened in the code editor
// will be analyzed.
Package::meta(),
);
Usually, you would implement the LSP server as a separate Rust executable program and bundle it with the code editor extension. The editor would run this program, establishing communication through the STDIO channel of the program's process.
The Language Server Setup example provides a sample setup of the language server.
In addition to the STDIO transport, some language clients also support TCP communication transport, where the LSP server is started independently from the client, and the client connects to the TCP port opened by the server (or by the client).
This communication mode is less common than the STDIO transport but is more useful for server debugging during the development of the code editor extension. In particular, if you start the server's process manually in the terminal, you will also see its STDERR debugging logs in the terminal.
The Ad Astra built-in server supports both types of communication transports.
The Language Client Example demonstrates a VS Code extension that works with the Ad Astra LSP Server through one of the transports, depending on the user's preference.
Web Assembly
The Ad Astra crate supports WebAssembly (Wasm) build targets, specifically the
wasm32-unknown-unknown
target.
The compiled Wasm module can be loaded and run in browsers, including the script language static analyzer, runner, and language server. However, this setup requires additional preparations.
The WebAssembly Example provides a full-featured setup of these components for the browser environment. You can see it in action in the Ad Astra Playground.
Exports
For desktop build targets, the Export
macro automatically exports
introspection metadata by linking the generated exporting functions in a special
link section.
However, the Wasm build target does not have a fully-featured linker. Therefore, you must call these functions manually after the Wasm module is loaded and before the module is actually used.
// Loading a WebAssembly file.
const assembly = fetch('./wasm.wasm');
// Compiling the file in the browser.
WebAssembly.instantiateStreaming(assembly, IMPORTS).then(({instance}) => {
// Calling each module-exported function that starts with the special
// `__ADASTRA_EXPORT_` prefix.
//
// These functions are generated by the Export macro.
// By invoking them manually, you register the corresponding item's
// introspection metadata in the Ad Astra script engine's exports registry.
for (const property in instance.exports) {
if (property.startsWith('__ADASTRA_EXPORT_')) {
instance.exports[property]();
}
}
// The module is now ready for use.
});
LSP Server
The built-in LSP server that you would normally run using the LspServer::startup function spawns additional threads for the communication channel and the actual LSP server instance.
Common LSP communication transports such as STDIO and TCP are unavailable in the Wasm environment. Additionally, the browser's Wasm environment does not support multi-threading.
To organize the LSP server in this environment, you would typically instantiate the LSP server manually using the LspServer::new function and manually manage communication between the language server and the language client.
The WebAssembly Example example demonstrates running the Wasm module in a separate web worker, establishing communication through a system of serialized post-messages passed between the worker and the browser environment.
The LspServer::handle function processes deserialized incoming LSP messages, synchronously yielding outgoing messages. The corresponding implementation reads these messages on the Rust side, serializes them into strings, and sends these strings back to the browser environment.