Crate arithmetic_eval

source ·
Expand description

Simple interpreter for ASTs produced by arithmetic-parser.

How it works

  1. A Block of statements is compiled into an ExecutableModule. Internally, compilation processes the AST of the block and transforms it into a non-recusrive form. An ExecutableModule may require imports (such as NativeFns or constant Values), which can be taken from an Environment.
  2. ExecutableModule can then be executed, for the return value and/or for the changes at the top-level variable scope. There are two major variables influencing the execution outcome. An arithmetic is used to define arithmetic ops (+, unary and binary -, *, /, ^) and comparisons (==, !=, >, <, >=, <=). Imports may be redefined at this stage as well.

Type system

Values have 5 major types:

  • Primitive values corresponding to literals in the parsed Block
  • Boolean values
  • Functions, which are further subdivided into native functions (defined in the Rust code) and interpreted ones (defined within a module)
  • Tuples / arrays.
  • Objects.

Besides these types, there is an auxiliary one: OpaqueRef, which represents a reference-counted native value, which can be returned from native functions or provided to them as an arg, but is otherwise opaque from the point of view of the interpreted code (cf. anyref in WASM).

Semantics

  • All variables are immutable. Re-declaring a var shadows the previous declaration.
  • Functions are first-class (in fact, a function is just a variant of the Value enum).
  • Functions can capture variables (including other functions). All captures are by value.
  • Arithmetic operations are defined on primitive values, tuples and objects. Ops on primitives are defined via an Arithmetic. With tuples and objects, operations are performed per element / field. Binary operations require tuples of the same size / objects of the same shape, or a tuple / object and a primitive value. As an example, (1, 2) + 3 and #{ x: 2, y: 3 } / #{ x: 4, y: 5 } are valid, but (1, 2) * (3, 4, 5) isn’t.
  • Similar to ReScript, methods are considered syntactic sugar for functions, with the method receiver considered the first function argument. For example, (1, 2).map(sin) is equivalent to map((1, 2), sin). To specify namespaced functions, you can specify a method name in a {} block: (1, 2).{Array.map}(sin). There’s no magic here; the method name expression is just another expression. Thus, something like x.{curried(2)}(y, z) is a valid method call equivalent to curried(2)(x, y, z).
  • No type checks are performed before evaluation.
  • Type annotations and type casts are completely ignored. This means that the interpreter may execute code that is incorrect with annotations (e.g., assignment of a tuple to a variable which is annotated to have a numeric type).

Value comparisons

Equality comparisons (==, !=) are defined on all types of values.

  • For bool values, the comparisons work as expected.
  • For functions, the equality is determined by the pointer (2 functions are equal iff they alias each other).
  • OpaqueRefs either use the PartialEq impl of the underlying type or the pointer equality, depending on how the reference was created; see OpaqueRef docs for more details.
  • Equality for primitive values is determined by the Arithmetic.
  • Tuples are equal if they contain the same number of elements and elements are pairwise equal.
  • Different types of values are always non-equal.

Order comparisons (>, <, >=, <=) are defined for primitive values only and use OrdArithmetic.

Tuples

  • Tuples are created using a Tuple expression, e.g., (x, 1, 5).
  • Indexing for tuples is performed via FieldAccess with a numeric field name: xs.0. Thus, the index is always a “compile-time” constant. An error is raised if the index is out of bounds or the receiver is not a tuple.
  • Tuples can be destructured using a Destructure LHS of an assignment, e.g., (x, y, ...) = (1, 2, 3, 4). An error will be raised if the destructured value is not a tuple, or has an incompatible length.

Objects

  • Objects can be created using object expressions, which are similar to ones in JavaScript. For example, #{ x: 1, y: (2, 3) } will create an object with two fields: x equal to 1 and y equal to (2, 3). Similar to Rust / modern JavaScript, shortcut field initialization is available: #{ x, y } will take vars x and y from the context.
  • Object fields can be accessed via FieldAccess with a field name that is a valid variable name. No other values have such fields. An error will be raised if the object does not have the specified field.
  • Objects can be destructured using an ObjectDestructure LHS of an assignment, e.g., { x, y } = obj. An error will be raised if the destructured value is not an object or does not have the specified fields. Destructuring is not exhaustive; i.e., the destructured object may have extra fields.
  • Functional fields are permitted. Similar to Rust, to call a function field, it must be enclosed in parentheses: (obj.run)(arg0, arg1) or braces: {obj.run}(arg0, arg1).

Crate features

std

(On by default)

Enables support of types from std, such as the Error trait, and propagates to dependencies. Importantly, std is necessary for floating-point arithmetics.

hashbrown

(Off by default)

Imports hash maps and sets from the eponymous crate instead of using ones from the Rust std library. This feature is necessary if the std feature is disabled.

complex

(Off by default)

Implements Number for floating-point complex numbers from the num-complex crate (i.e., Complex32 and Complex64). Enables complex number parsing in arithmetic-parser.

bigint

(Off by default)

Implements Number and a couple of other helpers for big integers from the num-bigint crate (i.e., BigInt and BigUint). Enables big integer parsing in arithmetic-parser.

Examples

use arithmetic_parser::grammars::{F32Grammar, Parse, Untyped};
use arithmetic_eval::{
    env::{Assertions, Comparisons, Environment, Prelude}, ExecutableModule, Value,
};

let program = "
    // The interpreter supports all parser features, including
    // function definitions, tuples and blocks.
    order = |x, y| (min(x, y), max(x, y));
    assert_eq(order(0.5, -1), (-1, 0.5));
    (_, M) = order(3^2, { x = 3; x + 5 });
    M
";
let program = Untyped::<F32Grammar>::parse_statements(program)?;
// To execute statements, we first compile them into a module.
let module = ExecutableModule::new("test", &program)?;

// Then, we construct an environment to run the module.
let mut env = Environment::new();
// Add some native functions to the environment.
env.extend(Prelude::iter());
env.extend(Assertions::iter());
env.extend(Comparisons::iter());

// Then, the module can be run.
assert_eq!(module.with_env(&env)?.run()?, Value::Prim(9.0));

Using objects:

let program = "
    minmax = |...xs| xs.fold(#{ min: INF, max: -INF }, |acc, x| #{
         min: if(x < acc.min, x, acc.min),
         max: if(x > acc.max, x, acc.max),
    });
    assert_eq(minmax(3, 7, 2, 4).min, 2);
    assert_eq(minmax(5, -4, 6, 9, 1), #{ min: -4, max: 9 });
";
let program = Untyped::<F32Grammar>::parse_statements(program)?;
let module = ExecutableModule::new("minmax", &program)?;

let mut env = Environment::new();
env.extend(Prelude::iter().chain(Assertions::iter()));
env.insert("INF", Value::Prim(f32::INFINITY));
module.with_env(&env)?.run()?;

More complex examples are available in the examples directory of the crate.

Re-exports

Modules

Structs

  • Context for native function calls.
  • Function defined within the interpreter.
  • Object with zero or more named fields.
  • Opaque reference to a native value.
  • Tuple of zero or more values.

Enums

  • Function definition. Functions can be either native (defined in the Rust code) or defined in the interpreter.
  • Values produced by expressions during their interpretation.
  • Possible high-level types of Values.

Traits

Type Aliases

  • Value together with a span that has produced it.