Un PoC de REPL para Dart

Hackeando con Dart

Un colibrí de origami en la combinación de colores original de Dart.

Python tiene un modo interactivo genial. Dart no tiene un modo interactivo en este momento, pero es realmente bueno para prototipar ideas rápidamente, así que ¡veamos si podemos hackear algo juntos!

Aviso: Trabajo en Google, pero este post es sobre un proyecto personal. No estoy en el equipo de Dart ni nada relacionado. Esta es solo mi humilde opinión y mi historia. Acompáñame.

El modo interactivo en lenguajes como Python o Ruby ha ayudado a hacerlos muy accesibles para principiantes. Pero estos no son los únicos que usan el concepto de REPL. Un REPL es un ciclo read-eval-print. Shells como BASH o zsh también usan REPLs cuando interactúas con ellos en una terminal. También es lo que obtienes cuando trabajas con notebooks en Jupyter (IPython Notebooks), Matlab, Mathematica o Maple. Esta forma de computación interactiva es muy popular entre los investigadores.

Esto es lo que parece el modo interactivo de Python:

Un usuario interactúa con el REPL de Python.
El buen REPL de Python

Dart no soporta evaluar sentencias en un scope global como Python, así que no hay una forma obvia de hacerlo correctamente. En Dart, las sentencias tienen que estar dentro de funciones y la función main es el punto de entrada de un programa. Igual que en C++ o C#. Personalmente, prefiero esto porque facilita averiguar qué está pasando cuando un programa se ejecuta. Pero… aún así me gustaría mucho tener un modo interactivo. Me permitiría jugar con ideas y probar cosas aún más rápido. Así que ¡veamos si podemos crear un REPL como prueba de concepto!

¿Cómo podemos crear un REPL en Dart?

#

Dart es realmente bueno para prototipar. Así que hagamos justo eso y no nos preocupemos por preguntas de diseño del lenguaje :)

Ahora no hay función eval como en JavaScript o Python, y no quiero escribir un intérprete completo yo mismo para implementar una. No sería muy rápido y tampoco tengo mucho tiempo. Sin embargo, cuando depuras código Dart en Intellij, puedes evaluar expresiones mientras recorres tu código. Evaluar expresiones es exactamente lo que queremos hacer, ¿no? ¿Podemos usar esta característica?

Las capacidades de depuración de Dart están expuestas a través de su VM service. Es un servicio JSON-RPC proporcionado por la Dart VM al que puedes conectarte para depurar tu aplicación. Natalie Weizenbaum ha publicado un artículo sobre el paquete vm_service_client que proporciona una API muy agradable para interactuar con el VM service: http://news.dartlang.org/2016/05/unboxing-packages-vmserviceclient.html

Ahora lo que podemos hacer es: ¡nuestro REPL puede conectarse a su propio VM service para evaluar expresiones que lee desde la terminal! Eso suena loco…

Persona sonriendo mientras sostiene un plátano, superpuesto con el texto 'You are so crazy lol'.

… ¡pero funciona! He escrito un spike rápido y efectivamente funciona. Dart soporta programación asincrónica, lo cual es muy útil ya que evita que el programa se bloquee a sí mismo al hablar con su propio VM service.

Los spikes son un concepto del desarrollo guiado por pruebas: son experimentos rápidos y sucios que uno escribe para resolver algunas preguntas técnicas.

Con la cuestión de factibilidad resuelta, es fácil escribir una prueba de concepto adecuada. Para evaluar expresiones que van más allá de 1 + 1, necesitamos soportar variables. Ahora las variables no se pueden crear fácilmente porque una declaración de variable no es una expresión en Dart. En Python, puedes declarar una variable al vuelo asignándole un valor. En Dart, podemos simular campos dinámicos sobrecargando n[oSuchMethod](https://www.dartlang.org/articles/language/emulating-functions#interactions-with-mirrors-and-nosuchmethod) para crear elementos en un diccionario al vuelo. Llamo a esta clase Scope y cuando evaluamos expresiones dentro de una instancia de ella, los campos pueden ser accedidos como globales.

Persona confundida sosteniendo su cabeza, superpuesto con el texto 'What do these words mean'.

El código es en realidad más directo:

dart
// Copyright (c) 2016, Andreas 'blackhc' Kirsch. All rights reserved. Use of
// this source code is governed by a BSD-style license that can be found in the
// LICENSE file.
import 'dart:mirrors';

Scope currentScope;

@proxy
class Scope {
  final _scope = <Symbol, dynamic>{};

  Scope();

  Scope.predefined(Map<Symbol, dynamic> symbols) {
    _scope.addAll(symbols);
  }

  factory Scope.clone(Scope other) => new Scope.predefined(other._scope);

  @override
  dynamic noSuchMethod(Invocation invocation) {
    if (invocation.isGetter) {
      if (_scope.containsKey(invocation.memberName)) {
        return _scope[invocation.memberName];
      } else {
        return super.noSuchMethod(invocation);
      }
    } else if (invocation.isSetter) {
      final variable = MirrorSystem.getSymbol(
          MirrorSystem.getName(invocation.memberName).split('=').first);
      _scope[variable] = invocation.positionalArguments.first;
      return null;
    } else if (invocation.isMethod) {
      return Function.apply(_scope[invocation.memberName] as Function,
          invocation.positionalArguments, invocation.namedArguments);
    } else {
      throw new UnsupportedError('Neither setter, nor getter, nor method!');
    }
  }

  @override
  String toString() => _scope.toString();
}

Con esto, ya podemos hacer cosas como a = 3y b = a*3:

Un usuario evalúa expresiones simples en el REPL de Dart.
Expresiones simples: HECHO

Una limitación con la que nos encontramos rápidamente es que solo podemos acceder a símbolos que han sido importados en el archivo que declara la clase Scope. import '…'; no puede ser evaluado usando el VM service. Así que no dart:io si no lo importamos explícitamente, y no librerías personalizadas :(

¡Espera! Dart puede generar nuevos isolates (workers independientes) usando un URI con Isolate.spawnUri. Los usuarios podrían especificar imports adicionales en la línea de comandos, y el REPL podría generar código fuente para incluir estos imports y luego generar un nuevo isolate usando el código generado que tiene los imports disponibles para el usuario.

Persona inclinándose hacia adelante en conversación, superpuesto con el texto 'That's not enough. We have to go deeper'.

Y funciona \o/

Un usuario importa exitosamente librerías personalizadas en el REPL de Dart.
Imports personalizados: HECHO

Soportando más Dart

#

Ahora otro problema es que solo podemos evaluar expresiones. Sentencias de control como bloques if/else o bucles while no son expresiones. Para las sentencias, podríamos envolverlas en un closure y ejecutar el closure, que es una expresión de llamada a función. Así, if (a == 1) print('a is 1!!'); se convertiría en

() { if (a == 1) print('a is 1!!'); }();

Solo necesitamos averiguar si la entrada es una expresión o una sentencia. Esto es difícil porque tendríamos que escribir un parser de Dart para esto. Pero Dart está escrito en Dart, y el paquete analyzer proporciona un parser que puede parsear cualquier código Dart gratis.

dart
// Copyright (c) 2016, Andreas 'blackhc' Kirsch. All rights reserved. Use of
// this source code is governed by a BSD-style license that can be found in the
// LICENSE file.
import 'dart:io';

import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/src/dart/scanner/reader.dart';
import 'package:analyzer/src/dart/scanner/scanner.dart';
import 'package:analyzer/src/generated/parser.dart';

bool _tryParse(String code, Function parse) {
  final reader = new CharSequenceReader(code);
  final errorListener = new BooleanErrorListener();
  final scanner = new Scanner(null, reader, errorListener);
  final token = scanner.tokenize();
  final parser = new Parser(null, errorListener);
  final node = parse(parser, token) as AstNode;

  return !errorListener.errorReported &&
      node != null &&
      node.endToken.next.type == TokenType.EOF;
}

bool isExpression(String code) => _tryParse(
    code, (Parser parser, Token token) => parser.parseExpression(token));

bool isStatements(String code) => _tryParse(code, (Parser parser, Token token) {
      final statements = parser.parseStatements(token);
      if (statements.isEmpty) {
        return null;
      }
      return statements.last;
    });

Y así, eso también está resuelto.

Un usuario evalúa tanto sentencias como expresiones en el REPL de Dart.
Sentencias y expresiones: HECHO

Más imports

#

El último detalle que nos impide importar cualquier librería es que por defecto un nuevo Isolate solo ve los paquetes mencionados en su pubspec.yaml. Queremos soportar importar cualquier librería sin embargo. Isolate.spawnUri tiene un parámetro packageConfig que nos permite especificar un map de nombre de paquete a ruta de paquete. Podemos simplemente usar otro parámetro de línea de comandos para apuntar a otro paquete y usar su package config en nuestro Isolate. ¡Woohoo!

Rápidamente nos encontramos con el problema de que nuestro Isolate necesita acceder al paquete analyzer (y otros) que podrían no estar cargados por cualquier paquete con el que quieras jugar en tu sesión de REPL. ¡El paquete package_resolver al rescate! Con él, podemos manipular fácilmente las configuraciones de paquetes.

dart
import 'package:package_resolver/package_resolver.dart';

Future<PackageResolver> mergePackageConfigs(String otherPackageRoot) async {
  final currentConfig = await PackageResolver.current.packageConfigMap;
  final otherConfig =
      (await SyncPackageResolver.loadConfig(otherPackageRoot + '/.packages'))
          .packageConfigMap;
  // We do something horrible here: We simply merge the current config with
  // the other. Hoping it will still work..
  final config = <String, Uri>{};
  config.addAll(currentConfig);
  config.addAll(otherConfig);
  return new PackageResolver.config(config);
}

Con todo esto, tenemos un flujo de trabajo completo implementado:

Un usuario importa una librería de otro paquete en el REPL de Dart.
Importar cualquier librería de otro paquete: HECHO

¿Qué sigue?

#

Este fue un post largo… Toda la prueba de concepto suma 451 líneas de código, casi tan largo como este post.

Meme de Grumpy Cat con el texto 'THE END IS NEAR - GOOD'.

El código se puede encontrar en https://github.com/BlackHC/dart_repl. Puedes probarlo fácilmente si tienes Dart instalado:

pub global activate dart_repl
pub global run dart_repl

Realmente disfruté creando esta prueba de concepto en mi tiempo libre. Todas las piezas encajaron en un par de horas. El soporte IDE para Dart en IntelliJ es excelente, y hay muchísima documentación y artículos ahora. Revisa la serie Unboxing Packages de Natalie Weizenbaum por ejemplo: http://news.dartlang.org/2016/04/unboxing-packages-async-part-3.html entre otros. Hacking a bajo nivel en Dart es divertido, y hay grandes librerías para ser creativo. code_builder se ve muy prometedor y built_collection proporciona colecciones inmutables. David Morgan también ha estado publicando artículos sobre colecciones inmutables en Dart.

Para dart_repl, sería bueno importar librerías adicionales en runtime sin reinicios. El equipo de Dart ha añadido recientemente soporte para hot reloading al VM. Esto principalmente proporciona una mejor experiencia en Flutter para desarrolladores de apps móviles. Tal vez, esto podría usarse para imports adhoc y para definir funciones y clases en el REPL también.

En general, Dart podría ser genial para investigación y para investigadores, y me encantaría ver soporte de Jupyter para Dart. Solo habría que implementar su interfaz de kernel para hacer un REPL de Dart compatible con ello… :) Eso debería ser fácil, ¿no?

Meme de cierre sobre la prueba de concepto del REPL de Dart con el texto 'You Deserve A Cookie'.

Más de Dart