4 releases (breaking)

0.5.0 Jul 30, 2023
0.4.0 Jul 30, 2023
0.3.0 Jul 5, 2023
0.2.0 Jul 3, 2023

#1157 in Programming languages

45 downloads per month
Used in 2 crates (via steel-interpreter)

MIT/Apache

2MB
39K SLoC

Steel

An embeddable and extensible scheme dialect built in Rust.

Actions Status Actions Status Coverage Status Discord Chat Matrix Chat

Try it on the Playground · Read the Steel book (WIP)

Getting Started

This github repository contains a cli interpreter. To try it out on the online playground, go to the Steel playground. To get started using a repl with the crates, make sure you first have rust installed.

Then, clone the repo and run the following command:

cargo run

This will launch a REPL instance that looks something like this:

Full install

If you'd like to install everything, just run the following command:

cargo xtask install

This will install:

  • The steel interpreter, steel
  • The dylib installer, cargo-steel-lib (also available via the interpreter)
  • The steel language server
  • The standard library, found under the cogs directory

Packages

If you would like to customize the location of installed packages, please set the STEEL_HOME environment variable. Steel currently assumes the default of $HOME/.steel if the environment variable is not already set.

About

Steel is an embeddable scheme interpreter, with a standalone cli included as well. Inspired largely by Racket, the language seeks to be ergonomic scheme variant helpful for embedding in applications, or to be used on its own with high performance functions implemented in Rust. The language implementation itself contains a fairly powerful macro system based on the syntax-rules style and a bytecode virtual machine. At the moment, it is not explicitly compliant with any individual scheme specification.

Warning The API is unstable with no guarantees, and may change at any time while pre 1.0. There are undoubtedly bugs that exist, and any major bug reports will be addressed quickly. That being said, I do use it as a daily driver for many scripting tasks myself.

Features

  • Limited syntax-rules style macros are supported
  • Easy integration with Rust functions and structs
  • Easily call a script from rust or via a separate file
  • Efficient - common functions and data structures are optimized for performance (map, filter, etc)
  • Higher order Contracts
  • Built in immutable data structures include:
    • lists
    • vectors
    • hashmaps
    • hashsets

Contracts

Inspired by Racket's higher order contracts, Steel implements* higher order contracts to enable design by contract, made easy with a define/contract macro for easier ergonomics. Racket makes use of a concept known as blame which seeks to identify the violating party - Steel does not quite have fully fleshed out blame but that is a work in progress. Here are some examples:

;; Simple flat contracts
(define/contract (test x y)
    (->/c even? even? odd?)
    (+ x y 1))

(test 2 2) ;; => 5

(define/contract (test-violation x y)
    (->/c even? even? odd?)
    (+ x y 1))

(test-violation 1 2) ;; contract violation

Contracts are implemented as values, so they are bound to functions. This enables the use of contract checking on functions themselves since functions can be passed around:

;; Higher order contracts, check on application
(define/contract (higher-order func y)
    (->/c (->/c even? odd?) even? even?)
    (+ 1 (func y)))

(higher-order (lambda (x) (+ x 1)) 2) ;; => 4

(define/contract (higher-order-violation func y)
    (->/c (->/c even? odd?) even? even?)
    (+ 1 (func y)))

(higher-order-violation (lambda (x) (+ x 2)) 2) ;; contract violation

Contracts on functions do not get checked until they are applied, so a function returning a contracted function won't cause a violation until that function is actually used:

;; More higher order contracts, get checked on application
(define/contract (output)
    (->/c (->/c string? int?))
    (lambda (x) 10))

(define/contract (accept func)
    (->/c (->/c string? int?) string?)
    "cool cool cool")

(accept (output)) ;; => "cool cool cool"

;; different contracts on the argument
(define/contract (accept-violation func)
    (->/c (->/c string? string?) string?)
    (func "applesauce")
    "cool cool cool")

(accept-violation (output)) ;; contract violation

;; generates a function
(define/contract (generate-closure)
    (->/c (->/c string? int?))
    (lambda (x) 10))

;; calls generate-closure which should result in a contract violation
(define/contract (accept-violation)
    (->/c (->/c string? string?))
    (generate-closure))

((accept-violation) "test") ;; contract violation

Perhaps a more nuanced case:

(define/contract (output)
    (->/c (->/c string? int?))
    (lambda (x) 10.2))

(define/contract (accept)
    (->/c (->/c string? number?))
    (output))


((accept) "test") ;; contract violation 10.2 satisfies number? but _not_ int?

* Very much a work in progress

Transducers

Inspired by clojure's transducers, Steel has a similar object that is somewhere half way in between transducers and iterators. Consider the following:


(mapping (lambda (x) (+ x 1))) ;; => <#iterator>
(filtering even?) ;; => <#iterator>
(taking 15) ;; => <#iterator>

(compose
    (mapping add1)
    (filtering odd?)
    (taking 15)) ;; => <#iterator>

Each of these expressions emit an <#iterator> object, which means they're compatible with transduce. transduce takes a transducer (i.e. <#iterator>) and a collection that can be iterated (list, vector, stream, hashset, hashmap, string, struct) and applies the transducer.

;; Accepts lists
(transduce (list 1 2 3 4 5) (mapping (lambda (x) (+ x 1))) (into-list)) ;; => '(2 3 4 5 6)

;; Accepts vectors
(transduce (vector 1 2 3 4 5) (mapping (lambda (x) (+ x 1))) (into-vector)) ;; '#(2 3 4 5 6)

;; Even accepts streams!
(define (integers n)
    (stream-cons n (lambda () (integers (+ 1 n)))))

(transduce (integers 0) (taking 5) (into-list)) ;; => '(0 1 2 3 4)

Transduce accepts a reducer function as well. Above we used into-list and into-vector, but below we can use any arbitrary reducer:

;; (-> transducer reducing-function initial-value iterable)
(transduce (list 0 1 2 3) (mapping (lambda (x) (+ x 1))) (into-reducer + 0)) ;; => 10

Compose just combines the iterator functions and lets us avoid intermediate allocation. The composition works left to right - it chains each value through the functions and then accumulates into the output type. See the following:

(define xf
    (compose
        (mapping add1)
        (filtering odd?)
        (taking 5)))

(transduce (range 0 100) xf (into-list)) ;; => '(1 3 5 7 9)

Syntax Choices

Steel is mildly opinionated in that there a few ways to define variables and functions. These choices are fairly arbitrary except for the shorthand function syntax, which I borrowed from Racket. defn and fn were really encouraged by me wanting to type less characters.


;; All of the following are equivalent
(define (foo x) (+ x 1))
(define foo (lambda (x) (+ x 1)))
(defn (foo x) (+ x 1))
(defn foo (lambda (x) (+ x 1)))

;; All of the following are equivalent
(lambda (x) (+ x 1))
(λ (x) (+ x 1))
(fn (x) (+ x 1))

Modules

In order to support a growing codebase, Steel has module support for projects spanning multiple files. Steel files can provide values (with contracts attached) and require modules from other files:

;; main.scm
(require "provide.scm")

(even->odd 10)


;; provide.scm
(provide
    (contract/out even->odd (->/c even? odd?))
    no-contract
    flat-value)

(define (even->odd x)
    (+ x 1))

(define (accept-number x) (+ x 10))

(define (no-contract) "cool cool cool")
(define flat-value 15)

(displayln "Calling even->odd with some bad inputs but its okay")
(displayln (even->odd 1))

Here we can see if we were to run main that it would include the contents of provide, and only provided values would be accessible from main. The contract is attached at the contract boundary, so inside the provide module, you can violate the contract, but outside the module the contract will be applied.

A few notes on modules:

  • Cyclical dependencies are not allowed
  • Modules will be only compiled once and used across multiple files. If A requires B and C, and B requires C, C will be compiled once and shared between A and B.
  • Modules will be recompiled when changed, and any dependent files will also be recompiled as necessary

Performance

Preliminary benchmarks show the following on my machine:

Benchmark Steel Python
(fib 28) 63.383ms 65.10 ms
(ack 3 3) 0.303 ms 0.195 ms

Examples of embedding Rust values in the virtual machine

Rust values, types, and functions are easily embedded into Steel. Using the register_fn call, you can embed functions easily:

use steel_vm::engine::Engine;
use steel_vm::register_fn::RegisterFn;

fn external_function(arg1: usize, arg2: usize) -> usize {
    arg1 + arg2
}

fn option_function(arg1: Option<String>) -> Option<String> {
    arg1
}

fn result_function(arg1: Option<String>) -> Result<String, String> {
    if let Some(inner) = arg1 {
        Ok(inner)
    } else {
        Err("Got a none".to_string())
    }
}

pub fn main() {
    let mut vm = Engine::new();

    // Here we can register functions
    // Any function can accept parameters that implement `FromSteelVal` and
    // return values that implement `IntoSteelVal`
    vm.register_fn("external-function", external_function);

    // See the docs for more information about `FromSteelVal` and `IntoSteelVal`
    // but we can see even functions that accept/return Option<T> or Result<T,E>
    // can be registered
    vm.register_fn("option-function", option_function);

    // Result values will map directly to errors in the VM and bubble back up
    vm.register_fn("result-function", result_function);

    vm.run(
        r#"
        (define foo (external-function 10 25))
        (define bar (option-function "applesauce"))
        (define baz (result-function "bananas"))
    "#,
    )
    .unwrap();

    let foo = vm.extract::<usize>("foo").unwrap();
    println!("foo: {}", foo);
    assert_eq!(35, foo);

    // Can also extract a value by specifying the type on the variable
    let bar: String = vm.extract("bar").unwrap();
    println!("bar: {}", bar);
    assert_eq!("applesauce".to_string(), bar);

    let baz: String = vm.extract("baz").unwrap();
    println!("baz: {}", baz);
    assert_eq!("bananas".to_string(), baz);
}

We can also embed structs themselves:

use steel_vm::engine::Engine;
use steel_vm::register_fn::RegisterFn;

use steel_derive::Steel;

// In order to register a type with Steel,
// it must implement Clone, Debug, and Steel
#[derive(Clone, Debug, Steel, PartialEq)]
pub struct ExternalStruct {
    foo: usize,
    bar: String,
    baz: f64,
}

impl ExternalStruct {
    pub fn new(foo: usize, bar: String, baz: f64) -> Self {
        ExternalStruct { foo, bar, baz }
    }

    // Embedding functions that take self by value
    pub fn method_by_value(self) -> usize {
        self.foo
    }

    pub fn method_by_reference(&self) -> usize {
        self.foo
    }

    // Setters should update the value and return a new instance (functional set)
    pub fn set_foo(mut self, foo: usize) -> Self {
        self.foo = foo;
        self
    }
}

pub fn main() {
    let mut vm = Engine::new();

    // Registering a type gives access to a predicate for the type
    vm.register_type::<ExternalStruct>("ExternalStruct?");

    // Structs in steel typically have a constructor that is the name of the struct
    vm.register_fn("ExternalStruct", ExternalStruct::new);

    // register_fn can be chained
    vm.register_fn("method-by-value", ExternalStruct::method_by_value)
        .register_fn("method-by-reference", ExternalStruct::method_by_reference)
        .register_fn("set-foo", ExternalStruct::set_foo);

    let external_struct = ExternalStruct::new(1, "foo".to_string(), 12.4);

    // Registering an external value is fallible if the conversion fails for some reason
    // For instance, registering an Err(T) is fallible. However, most implementation outside of manual
    // ones should not fail
    vm.register_external_value("external-struct", external_struct)
        .unwrap();

    let output = vm
        .run(
            r#"
            (define new-external-struct (set-foo external-struct 100))
            (define get-output (method-by-value external-struct))
            (define second-new-external-struct (ExternalStruct 50 "bananas" 72.6))
            "last-result"
        "#,
        )
        .unwrap();

    let new_external_struct = vm.extract::<ExternalStruct>("new-external-struct").unwrap();
    println!("new_external_struct: {:?}", new_external_struct);
    assert_eq!(
        ExternalStruct::new(100, "foo".to_string(), 12.4),
        new_external_struct
    );

    // Can also extract a value by specifying the type on the variable
    let get_output: usize = vm.extract("get-output").unwrap();
    println!("get_output: {}", get_output);
    assert_eq!(1, get_output);

    let second_new_external_struct: ExternalStruct =
        vm.extract("second-new-external-struct").unwrap();
    println!(
        "second_new_external_struct: {:?}",
        second_new_external_struct
    );
    assert_eq!(
        ExternalStruct::new(50, "bananas".to_string(), 72.6),
        second_new_external_struct
    );

    // We also get the output of the VM as the value of every expression run
    // we can inspect the results just by printing like so
    println!("{:?}", output);
}

See the examples folder for more examples on embedding values and interacting with the outside world.

License

Licensed under either of

at your option.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

See CONTRIBUTING.md.

Dependencies

~12–22MB
~322K SLoC