ethanblake4 / dart_eval

Extensible Dart interpreter for Dart with full interop
https://pub.dev/packages/dart_eval
BSD 3-Clause "New" or "Revised" License
334 stars 40 forks source link
codepush compiler dart flutter interpreter

Build status License: BSD-3 Web example Star on Github

dart_eval is an extensible bytecode compiler and interpreter for the Dart language, written in Dart, enabling dynamic execution and codepush for Flutter and Dart AOT.

dart_eval pub package
flutter_eval pub package
eval_annotation pub package

The primary aspect of dart_eval's goal is to be interoperable with real Dart code. Classes created in 'real Dart' can be used inside the interpreter with a wrapper, and classes created in the interpreter can be used outside it by creating an interface and bridge class.

dart_eval's compiler is powered under the hood by the Dart analyzer, so it achieves 100% correct and up-to-date parsing. While compilation and execution aren't quite there yet, dart_eval has over 200 tests that are run in CI to ensure correctness.

Currently dart_eval implements a majority of the Dart spec, but there are still missing features like generators, Sets and extension methods. In addition, parts of the standard library haven't been implemented. See the language feature support table for details.

Usage

Note: See the README for flutter_eval for information on setting up Flutter code push.

A basic usage example of the eval method, which is a simple shorthand to execute Dart code at runtime:

import 'package:dart_eval/dart_eval.dart';

void main() {
  print(eval('2 + 2')); // -> 4

  final program = r'''
      class Cat {
        Cat(this.name);
        final String name;
        String speak() => "I'm $name!";
      }
      String main() {
        final cat = Cat('Fluffy');
        return cat.speak();
      }
  ''';

  print(eval(program, function: 'main')); // prints 'I'm Fluffy!'
}

Passing arguments

In most cases, you should wrap arguments you pass to dart_eval in $Value wrappers, such as $String or $Map. These 'boxed types' have information about what they are and how to modify them, and you can access their underlying value with the $value property. However, ints, doubles, bools, and Lists are treated as primitives and should be passed without wrapping when their exact type is specified in the function signature:

final program = '''
  int main(int count, String str) {
    return count + str.length;
  }
''';

print(eval(program, function: 'main', args: [1, $String('Hi!')])); // -> 4

When calling a function or constructor externally, you must specify all arguments - even optional and named ones - in order, using null to indicate the absence of an argument (whereas $null() indicates a null value).

Passing callbacks

You can pass callbacks as arguments to dart_eval using $Closure:

import 'package:dart_eval/dart_eval.dart';
import 'package:dart_eval/dart_eval_bridge.dart';

void main() {
  final program = '''
    void main(Function callback) {
      callback('Hello');
    }
  ''';

  eval(program, function: 'main', args: [
    $Closure((runtime, target, args) {
      print(args[0]!.$value + '!');
      return null;
    })
  ]); // -> prints 'Hello!'
}

Advanced usage

For more advanced usage, you can use the Compiler and Runtime classes directly, which will allow you to use multiple 'files' and customize how the program is run:

import 'package:dart_eval/dart_eval.dart';

void main() {
  final compiler = Compiler();

  final program = compiler.compile({'my_package': {
    'main.dart': '''
      import 'package:my_package/finder.dart';
      void main() {
        final parentheses = findParentheses('Hello (world)');
        if (parentheses.isNotEmpty) print(parentheses); 
      }
    ''',
    'finder.dart': r'''
      List<int> findParentheses(string) {
        final regex = RegExp(r'\((.*?)\)');
        final matches = regex.allMatches(string);
        return matches.map((match) => match.start).toList();
      }
    '''
  }});

  final runtime = Runtime.ofProgram(program);
  print(runtime.executeLib(
    'package:my_package/main.dart', 'main')); // prints '[6]'
}

Entrypoints and tree-shaking

dart_eval uses tree-shaking to avoid compiling unused code. By default, any file named main.dart or that contains runtime overrides will be treated as an entrypoint and guaranteed to be compiled in its entirety. To add additional entrypoints, append URIs to the Compiler.entrypoints array:

final compiler = Compiler();
compiler.entrypoints.add('package:my_package/some_file.dart');
compiler.compile(...);

Compiling to a file

If possible, it's recommended to pre-compile your Dart code to EVC bytecode, to avoid runtime compilation overhead. (This is still runtime code execution, it's just executing a more efficient code format.) Multiple files will be compiled to a single bytecode block.

import 'dart:io';
import 'package:dart_eval/dart_eval.dart';

void main() {
  final compiler = Compiler();

  final program = compiler.compile({'my_package': {
    'main.dart': '''
      int main() {
        var count = 0;
        for (var i = 0; i < 1000; i++) {
          count += i;
        }
        return count;
      }
    '''
  }});

  final bytecode = program.write();

  final file = File('program.evc');
  file.writeAsBytesSync(bytecode);
}

You can then load and execute the program later:

import 'dart:io';
import 'package:dart_eval/dart_eval.dart';

void main() {
  final file = File('program.evc');
  final bytecode = file
      .readAsBytesSync()
      .buffer
      .asByteData();

  final runtime = Runtime(bytecode);
  print(runtime.executeLib(
    'package:my_package/main.dart', 'main')); // prints '499500'
}

Using the CLI

The dart_eval CLI allows you to compile existing Dart projects to EVC bytecode, as well as run and inspect EVC bytecode files.

To enable the CLI globally, run:

dart pub global activate dart_eval

Compiling a project

The CLI supports compiling standard Dart projects. To compile a project, run:

cd my_project
dart_eval compile -o program.evc

This will generate an EVC file in the current directory called program.evc. dart_eval will attempt to compile Pub packages, but it's recommended to avoid them as they may use features that dart_eval doesn't support yet.

The compiler also supports compiling with JSON-encoded bridge bindings. To add these, create a folder in your project root called .dart_eval, add a bindings subfolder, and place JSON binding files there. The compiler will automatically load these bindings and make them available to your project.

Running a program

To run the generated EVC file, use:

dart_eval run program.evc -p package:my_package/main.dart -f main

Note that the run command does not support bindings, so any file compiled with bindings will need to be run in a specialized runner that includes the necessary runtime bindings.

Inspecting an EVC file

You can dump the op codes of an EVC file using:

dart_eval dump program.evc

Return values

Like with arguments, dart_eval will return a $Value wrapper for most values except ints, doubles, bools, and Lists. If you don't like this inconsistency, specifying a function's return value as dynamic will force dart_eval to always box the return value in a $Value wrapper.

Note that this does not apply to the eval() method, which automatically unboxes all return values for convenience.

Security and permissions

dart_eval is designed to be secure. The dart_eval runtime functions like a virtual machine, effectively sandboxing the code it executes. By default, the runtime will not allow running programs to access the file system, network, or other system resources, but these permissions can be enabled on a granular basis using runtime.grant:

final runtime = Runtime(bytecode);

// Allow full access to the file system
runtime.grant(FilesystemPermission.any);

// Allow access to a specific network domain
runtime.grant(NetworkPermission.url('example.com'));

// Allow access to a specific network resource
runtime.grant(NetworkPermission.url('https://dart.dev/api/users.json'));

// Using the eval() method
eval(source, permissions: [
  NetworkPermission.any,
  FilesystemReadPermission.directory('/home/user/mydata'), 
  ProcessRunPermission(RegExp(r'^ls$'))
]);

Permissions can also be revoked using runtime.revoke.

When writing bindings that access sensitive resources, you can check whether a permission is enabled using runtime.checkPermission, or assert using runtime.assertPermission. Out of the box, dart_eval includes the FilesystemPermission, NetworkPermission, and Process(Run/Kill)Permission classes ('filesystem', 'network', and 'process' domains, respectively) as well as read/write only variations of FilesystemPermission, but you can also create your own custom permissions by implementing the Permission interface.

Interop

Interop is a general term for methods in which we can access, use, and modify data from dart_eval in Dart. Enabling this access is a high priority for dart_eval.

There are three main levels of interop:

Value interop

Value interop happens automatically whenever dart_eval is working with an object backed by a real Dart value. (Therefore, an int and a string are value interop enabled, but a class created inside Eval isn't.) To access the backing object of a $Value, use its $value property. If the value is a collection like a Map or a List, you can use its $reified property to also unwrap the values it contains.

Wrapper interop

Using a wrapper enables the Eval environment to access the functions and fields on a class created outside Eval. It's much more powerful than value interop, and more performant than bridge interop, making it a great choice for certain use cases. To use wrapper interop, create a class that implements $Instance, and a compile-time class definition. Then, override $getProperty, $setProperty and $getRuntimeType to enable the Eval environment to access the class's properties and methods:

import 'package:dart_eval/dart_eval.dart';
import 'package:dart_eval/dart_eval_bridge.dart';
import 'package:dart_eval/dart_eval_extensions.dart';

/// An example class we want to wrap
class Book {
  Book(this.pages);
  final List<String> pages;

  String getPage(int index) => pages[index];
}

/// This is our wrapper class
class $Book implements $Instance {
  /// Create a type specification for the dart_eval compiler
  static final $type = BridgeTypeSpec('package:hello/book.dart', 'Book').ref;

  /// Create a class declaration for the dart_eval compiler
  static final $declaration = BridgeClassDef(BridgeClassType($type),
    constructors: {
      // Define the default constructor with an empty string
      '': BridgeFunctionDef(returns: $type.annotate, params: [
        'pages'.param(CoreTypes.list.refWith([CoreTypes.string.ref]).annotate)
      ]).asConstructor
    },
    methods: {
      'getPage': BridgeFunctionDef(
        returns: CoreTypes.string.ref.annotate,
        params: ['index'.param(CoreTypes.int.ref.annotate)],
      ).asMethod,
    }, wrap: true);

  /// Override $value and $reified to return the value
  @override
  final Book $value;

  @override
  get $reified => $value;

  /// Create a constructor that wraps the Book class
  $Book.wrap(this.$value);

  static $Value? $new(
    Runtime runtime, $Value? target, List<$Value?> args) {
    return $Book.wrap(Book(args[0]!.$value));
  }

  /// Create a wrapper for property and method getters
  @override
  $Value? $getProperty(Runtime runtime, String identifier) {
    if (identifier == 'getPage') {
      return $Function((_, target, args) {
        return $String($value.getPage(args[0]!.$value));
      });
    }
    return $Object(this).$getProperty(runtime, identifier);
  }

  /// Create a wrapper for property setters
  @override
  void $setProperty(Runtime runtime, String identifier, $Value value) {
    return $Object(this).$setProperty(runtime, identifier, value);
  }

  /// Allow runtime type lookup
  @override
  int $getRuntimeType(Runtime runtime) => runtime.lookupType($type.spec!);
}

/// Now we can use it in dart_eval!
void main() {
  final compiler = Compiler();
  compiler.defineBridgeClass($Book.$declaration);

  final program = compiler.compile({'hello' : { 
    'main.dart': '''
      import 'book.dart';
      void main() {
        final book = Book(['Hello world!', 'Hello again!']);
        print(book.getPage(1));
      }
    '''
  }});

  final runtime = Runtime.ofProgram(program);
  // Register static methods and constructors with the runtime
  runtime.registerBridgeFunc('package:hello/book.dart', 'Book.', $Book.$new);

  runtime.executeLib('package:hello/main.dart', 'main'); // -> 'Hello again!'
}

For more information, see the wrapper interop wiki page.

(Experimental) Binding generation for wrappers

As of v0.7.1 the dart_eval CLI includes an experimental wrapper binding generator. It can be invoked in a project using dart_eval bind, and will generate bindings for all classes annotated with the @Bind annotation from the eval_annotation package. You can also pass the '--all' flag to generate bindings for all classes in the project. Note that generated bindings don't support every edge case, and may require manual adjustment.

Binding generation cannot currently create JSON bindings directly, but you can use the generated Dart bindings to create JSON bindings using a BridgeSerializer.

Bridge interop

Bridge interop enables the most functionality: Not only can dart_eval access the fields of an object, but it can also be extended, allowing you to create subclasses within Eval and use them outside of dart_eval. For example, this can be used to create custom Flutter widgets that can be dynamically updated at runtime. Bridge interop is also in some ways simpler than creating a wrapper, but it comes at a performance cost, so should be avoided in performance-sensitive situations. To use bridge interop, extend the original class and mixin $Bridge:

// ** See previous example for the original class and imports **

/// This is our bridge class
class $Book$bridge extends Book with $Bridge<Book> {
  static final $type = ...; // See previous example
  static final $declaration = ...; // Previous example, but use bridge: true instead of wrap

  /// Recreate the original constructor
  $Book$bridge(super.pages);

  static $Value? $new(
    Runtime runtime, $Value? target, List<$Value?> args) {
    return $Book$bridge((args[0]!.$reified as List).cast());
  }

  @override
  $Value? $bridgeGet(String identifier) {
    if (identifier == 'getPage') {
      return $Function((_, target, args) {
        return $String(getPage(args[0]!.$value));
      });
    } 
    throw UnimplementedError('Unknown property $identifier');
  }

  @override
  $Value? $bridgeSet(String identifier) => 
    throw UnimplementedError('Unknown property $identifier');

  /// Override the original class' properties and methods
  @override
  String getPage(int index) => $_invoke('getPage', [$int(index)]);

  @override
  List<String> get pages => $_get('pages');
}

void main() {
  final compiler = Compiler();
  compiler.defineBridgeClass($Book$bridge.$declaration);

  final program = compiler.compile({'hello' : { 
    'main.dart': '''
      import 'book.dart';
      class MyBook extends Book {
        MyBook(List<String> pages) : super(pages);
        String getPage(int index) => 'Hello world!';
      }

      Book main() {
        final book = MyBook(['Hello world!', 'Hello again!']);
        return book;
      }
    '''
  }});

  final runtime = Runtime.ofProgram(program);
  runtime.registerBridgeFunc(
    'package:hello/book.dart', 'Book.', $Book$bridge.$new, bridge: true);

  // Now we can use the new book class outside dart_eval!
  final book = runtime.executeLib('package:hello/main.dart', 'main') 
    as Book;
  print(book.getPage(1)); // -> 'Hello world!'
}

An example featuring both bridge and wrapper interop is available in the example directory. For more information, see the wiki page on bridge classes.

Plugins

To configure interop for compilation and runtime, it's recommended to create an EvalPlugin which enables reuse of Compiler instances. Basic example:

class MyAppPlugin implements EvalPlugin {
  @override
  String get identifier => 'package:myapp';

  @override
  void configureForCompile(BridgeDeclarationRegistry registry) {
    registry.defineBridgeTopLevelFunction(BridgeFunctionDeclaration(
      'package:myapp/functions.dart',
      'loadData',
      BridgeFunctionDef(
          returns: BridgeTypeAnnotation(BridgeTypeRef(CoreTypes.object)), params: [])
    ));
    registry.defineBridgeClass($CoolWidget.$declaration);
  }

  @override
  void configureForRuntime(Runtime runtime) {
    runtime.registerBridgeFunc('package:myapp/functions.dart', 'loadData', 
        (runtime, target, args) => $Object(loadData()));
    runtime.registerBridgeFunc('package:myapp/classes.dart', 'CoolWidget.', $CoolWidget.$new);
  }
}

You can then use this plugin with Compiler.addPlugin and Runtime.addPlugin.

Runtime overrides

dart_eval includes a runtime overrides system that allows you to dynamically swap in new implementations of functions and constructors at runtime. To use it, add a null-coalescing call to the runtimeOverride() method at every spot you want to be able to swap:

void main() {
  // Give the override a unique ID
  final result = runtimeOverride('#myFunction') ?? myFunction();
  print(result);
}

String myFunction() => 'Original version of string';

Note that in some cases you may have to cast the return value of runtimeOverride as dart_eval is unable to specify generic parameters to the Dart type system.

Next, mark a function in the eval code with the @RuntimeOverride annotation:

@RuntimeOverride('#myFunction')
String myFunction() => 'Updated version of string'

Finally, follow the normal instructions to compile and run the program, but call loadGlobalOverrides on the Runtime. This will set the runtime as the single global runtime for the program, and load its overrides to be accessible by hot wrappers.

When the program is run, the runtime will automatically replace the function call with the new implementation.

Overrides can also be versioned, allowing you to roll out updates to a function immediately using dart_eval and revert to a new native implementation after an official update is released. To version an override, simply add a semver version constraint to the @RuntimeOverride annotation:

@RuntimeOverride('#login_page_get_data', version: '<1.4.0')

When running the program, specify its current version by setting the value of the runtimeOverrideVersion global property:

runtimeOverrideVersion = Version.parse('1.3.0');

Now, when the program is run, the runtime will automatically replace the instantiation only if the app version is less than 1.4.0.

Contributing

See Contributing.

FAQ

How does it work?

dart_eval is a fully Dart-based implementation of a bytecode compiler and runtime. First, the Dart analyzer is used to parse the code into an AST (abstract syntax tree). Then, the compiler looks at each of the declarations in turn, and recursively compiles to a linear bytecode format.

For evaluation dart_eval uses Dart's optimized dynamic dispatch. This means each bytecode is actually a class implementing EvcOp and we call its run() method to execute it. Bytecodes can do things like push and pop values on the stack, add numbers, and jump to other places in the program, as well as more complex Dart-specific operations like create a class.

See the in-depth overview wiki page for more information.

Does it support Flutter?

Yes! Check out flutter_eval.

How fast is it?

Preliminary testing shows that dart_eval running in AOT-compiled Dart is 10-50x slower than standard AOT Dart and is approximately on par with a language like Ruby. It's important to remember this only applies to code running directly in the dart_eval VM, and not any code it interacts with. For example, most Flutter apps spend the vast majority of their performance budget in the Flutter framework itself, so the speed impact of dart_eval is usually negligible.

Is this allowed in the App Store?

Though Apple's official guidelines are unclear, many popular apps use similar techniques to dynamically update their code. For example, apps built on React Native often use its custom Hermes JavaScript engine to enable dynamic code updates. Note that Apple is likely to remove apps if they introduce policy violations in updates, regardless of the technology used.

Language feature support table

The following table details the language features supported by dart_eval with native Dart code. Feature support may vary when bridging.

Feature Support level Tests
Imports [1], [2], [3]
Exports [1], [2]
part / part of [1]
show and hide [1]
Conditional imports N/A
Deferred imports N/A
Functions [1]
Anonymous functions [1], [2], [3], [4], [5], [6]
Arrow functions [1], [2]
Sync generators N/A
Async generators N/A
Tear-offs [1], [2], [3]
For loops [1], [2]
While loops [1]
Do-while loops [1]
For-each loops [1], [2]
Async for-each N/A
Switch statements N/A
Labels, break & continue Partial [1], [2]
If statements [1]
Try-catch [1], [2], [3], [4], [5]
Try-catch-finally [1], [2], [3], [4], [5]
Lists [1]
Iterable [1], [2]
Maps Partial [1], [2], [3], [4]
Sets N/A
Collection for [1], [2]
Collection if [1], [2]
Spreads N/A
Classes [1]
Class static methods [1], [2]
Getters and setters [1]
Factory constructors [1]
Redirecting constructors [1]
new keyword [1]
Class inheritance [1]
Abstract and implements Partial [1]
this keyword [1], [2]
super keyword [1]
Super constructor params [1]
Mixins N/A
Futures Partial [1], [2]
Async/await [1], [2], [3]
Streams Partial [1]
String interpolation [1]
Enums Partial [1], [2], [3]
Generic function types Partial [1]
Typedefs N/A
Generic classes Partial
Type tests (is) [1], [2]
Casting (as) [1], [2], [3]
assert [1]
Null safety Partial
Late initialization N/A
Cascades [1], [2]
Ternary expressions [1]
Null coalescing expressions [1], [2]
Extension methods N/A
Const expressions Partial N/A
Isolates N/A
Record types N/A
Patterns N/A

Features and bugs

Please file feature requests and bugs at the issue tracker. If you need help, use the discussion board.