Skip to content

Latest commit

 

History

77 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Rulox

Rulox es un tree-walking interpreter de Lox hecho en rust, para la materia Lenguajes y Compiladores. Su implementación esta basada en el libro Crafting Interpreters y en la implementación del curso; plox.

Indice

Como correrlo

Rulox puede ser ejecutado tanto en modo REPL como en modo archivo. Para utilizarlo es necesario contar con Rust instalado en el sistema.

Puede compilarse previamente, desde la raíz del proyecto, ejecutando:

# En modo debug
cargo build

# En modo release
cargo build --release

Para ejecutarlo en modo REPL:

# Con el binario compilado
./target/release/rulox

# Con cargo
cargo run

Para ejecutarlo en modo archivo:

# Con el binario compilado
./target/release/rulox /ruta/al/archivo.lox

# Con cargo
cargo run /ruta/al/archivo.lox

Tests

Pueden correrse los tests utilizando el comando:

cargo test

Esto ejecutará:

  • Los tests proporcionados por la cátedra, donde se revisa que no impriman ERROR, y que no haya errores en stderr. (ver real-tests/runner)
  • Tests agregados a lo largo de la implementación, inspirados en la forma de los tests de crafting interpreters, donde cada archivo .lox dentro de tests/cases (y subcarpetas) es un caso a probar. Incluyendo tanto resultados esperados (expect) como errores esperados (expect .. error). Mas sobre esto en tests/README.md

Ejemplos

Pueden verse varios ejemplos de uso en examples/, donde trata de mostarse ejemplos completos usando la mayor parte de las funcionalidades que provee rulox.

Se incluyeron en examples/benchmarks benchmarks pensados especificamente para medir ciertas partes del intérprete, tomados de los benchmarks de crafting interpreters

Los ejemplos que leen archivos (como map_reducer.lox que lee persons.csv) deben ejecutarse desde la raíz del repositorio para que las rutas relativas funcionen.

Benchmarks

Se comparó rulox contra intérpretes y compiladores de otros lenguajes en un conjunto de programas representativos. Las mediciones se hicieron con hyperfine (promedio de múltiples corridas, incluyendo warmup). El consumo de memoria corresponde al máximo RSS reportado por hyperfine.

Los lenguajes que comparamos son:

  • rulox
  • lua
  • python
  • javascript (Node)
  • go
  • rust

Como correrlos

# Correr todos los benchmarks
./benchmarks/run.sh

# Correr uno en particular
./benchmarks/run.sh <nombre>

# ej: trees
./benchmarks/run.sh trees

# Generar el reporte consolidado con tablas de tiempo, velocidad relativa y memoria
./benchmarks/report.sh

# Los resultados individuales quedan en benchmarks/<nombre>/results.{md,json}
# El reporte consolidado se escribe en benchmarks/results.md

Cada benchmark tiene un parámetro n al inicio del archivo (main.lox, main.py, etc.) para ajustar la carga de trabajo. Decidimos mantener un n lo mas alto posible, pero tal que a rulox no le lleve mas de unos ~15s correrlo, para que ejecutar los benchmarks no lleve una cantidad muy grande de tiempo.

Resultados

resultados/benchmarks:

Tiempo (ms)

Media por benchmark por lenguaje. Menor es mejor.

Benchmark rulox lua python js go rust
fannkuch 10189.79 339.55 677.55 139.04 19.38 13.41
fibonacci 11881.63 662.90 1256.26 201.91 51.48 30.38
nbody 13186.07 377.31 1231.85 210.71 93.87 82.64
quicksort 14136.75 282.13 856.34 132.59 11.66 8.83
sums 7387.22 146.10 1722.25 144.32 7.69 4.32
trees 10825.13 1807.51 10770.23 490.46 186.05 219.77

Velocidad relativa a rulox

Veces más rápido que rulox (mayor es mejor). 1.0 = igual que rulox.

Benchmark rulox lua python js go rust
fannkuch 1.00x 30.01x 15.04x 73.29x 525.78x 759.98x
fibonacci 1.00x 17.92x 9.46x 58.85x 230.78x 391.13x
nbody 1.00x 34.95x 10.70x 62.58x 140.47x 159.57x
quicksort 1.00x 50.11x 16.51x 106.62x 1211.99x 1600.52x
sums 1.00x 50.56x 4.29x 51.19x 960.31x 1709.00x
trees 1.00x 5.99x 1.01x 22.07x 58.19x 49.26x

Memoria (MB)

Pico de memoria RSS por benchmark. Menor es mejor.

Benchmark rulox lua python js go rust
fannkuch 3.62 3.62 9.69 58.69 58.69 58.69
fibonacci 3.77 3.77 9.39 58.24 58.24 58.24
nbody 3.71 3.71 9.70 61.48 61.48 61.48
quicksort 3.80 3.80 9.64 59.37 59.37 59.37
sums 3.69 3.69 9.45 60.98 60.98 60.98
trees 1539.42 1539.42 1539.42 1539.42 1539.42 1539.42

Diferencias con plox/jlox

Sobre el uso de rust

El uso de rust para la implementación de Rulox llevó en principio a diferencias no funcionales del lenguaje lox, pero sí a la necesidad de repensar en varias partes la implementación. Tanto en java como en python (versión del libro y del curso) el tipado dinámico permite mantener los objetos sin importar su tipo, y poder chequearlo/revisarlo de ser necesario al momento de su uso, lo cual encaja con el dinamismo de tipos de lox. En rust, en cambio, esto no es posible, toda variable tiene que tener un tipo definido en tiempo de compilación, esto llevó a implementar un tipo Object que encapsule todos los tipos posibles de una variable de Lox, en forma de enum:

src/object.rs

pub enum Object {
    Number(f64),
    String(String),
    Boolean(bool),
    Function(Rc<Function>),
    Array(Rc<RefCell<Array>>),
    Class(Rc<Class>),
    Instance(Rc<RefCell<Instance>>),
    Nil,
}

De esta forma, toda variable en rulox no es mas que un Object, y un environment por ejemplo, mapea nombres (String) a Object. Cuando se requiere realizar alguna operación particular sobre objetos que requiera conocer su tipo, utilizamos alguna variante de pattern matching:

src/interpreter.rs

fn evaluate_binary_from_numbers<F: Fn(f64, f64) -> Object>(
    &self,
    left: &Object,
    right: &Object,
    ...
) -> RuntimeResult<Object> {
    if let (Object::Number(a), Object::Number(b)) = (&left, &right) {
        Ok(evaluate(*a, *b))
    } else {
        Err(RuntimeError::NonNumericBinaryOperands(
            ..
        ))
    }

src/interpreter.rs

fn evaluate_call(
    &mut self,
    callee: &Expression,
    ..
) -> RuntimeResult<Object> {
    let evaluated_callee = self.evaluate(callee)?;
    match evaluated_callee {
        Object::Function(function) => {
            // Call function
            ..
        }
        Object::Class(class) => {
            // Instanciate a new instance
            ..
        }
        _ => Err(RuntimeError::NonCallable(
            ..
        )),
    }
}

Fue también necesario desde el momento en que implementamos environments manejar referencias y mutabilidad. Tanto en Java como en Python, el manejo de la cadena de environments se resuelve de forma directa ya que los objetos se pasan en todo momento por referencia mutable, en rust, en cambio, para pasar un objeto (struct) es necesario o bien clonarlo/copiarlo, o usar una referencia. El uso de referencias (&struct y &mut struct), de ser utilizado, conllevaría a mayor complejidad ya que el borrow checker no permite mantener múltiples referencias mutables (se convertiría en un problema cuando un environment tenga que ser referenciado tanto por otro environment, como como closure de una función), ni tampoco que convivan referencias mutables e inmutables. Tampoco es posible mantener referencias sin tener un manejo explícito de lifetimes que le aseguren al borrow checker que la referencia va a apuntar en todo tiempo a un objeto válido.

Por estas razones, decidimos utilizar Rc (referencias contadas, para poder mantener múltiples referencias hacia al mismo struct) y RefCell (para poder mutar el contenido dentro del Rc). El uso de este tipo de estructuras permitió el manejo mas dinámico de las referencias, moviendo muchos de los chequeos que hace el borrow checker en tiempo de compilación, a runtime.

Así, para guardar una referencia a un environment se utiliza el tipo: Rc<RefCell<Environment>>.

Luego de los environments, seguimos utilizando este tipo de estructuras para guardar referencias a objetos mas complejos del runtime donde clonarlos podría implicar una peor performance; como clases, donde implicaría clonar todos los statements de todos sus métodos o donde directamente no era posible; asignar una instancia o un array a una variable implica apuntar al mismo objeto. Cabe destacar aquí el uso unicamente de Rc cuando no se necesitaba mutabilidad (por ejemplo, funciones y clases) y Rc<RefCell> cuando sí era necesaria.

La ventaja de performance es que todos los objetos se mantienen de un tamaño pequeño (tamaño de un puntero en la arquitectura), por lo que no hay un costo al clonarlo cuando se necesita (simplemente se esta creando otro puntero a la misma dirección).

Errores

Como muchas veces más que desde el repl, el lenguaje se utiliza ejecutando archivos, decidimos tener un display de errores lo más claro posible. Esto nos llevó a mostrar, junto con cada error, en qué linea se produjo o se detectó. Para esto, el scanner incluye en cada token generado, la línea en la que se encuentra:

src/token.rs

pub struct Token {
    pub token: TokenType,
    pub lexeme: String,
    pub line: usize,
}

src/scanner.rs

fn add_token(&mut self, token_type: TokenType) {
    self.tokens
        .push(Token::new(..., self.line));
}

Con esto, cuando ocurre algun error, asociamos siempre algún token, y decimos entonces que el error es "localizable" gracias a ese token. En el caso de los errores de scanning, donde todavía no tenemos el concepto de tokens, guardamos en el error, la línea en donde ocurrió. Teniendo esta información, se simplifica el mostrar cualquier tipo de error.

src/errors/runtime.rs

impl Locatable for RuntimeError {
    fn line(&self) -> usize {
        match self {
            RuntimeError::NonNumericBinaryOperands(t, _, _)
            | .. => t.line
        }
    }
}

src/errors/rulox.rs

// Como todos los errores implementan Locatable, puede directamente llamarse a .line() sobre el mismo
pub enum RuloxError {
    #[error("[line {}] Scanning error: {}", .0.line(), .0)]
    Scanning(#[from] ScanningError),
    #[error("[line {}] Parsing error: {}", .0.line(), .0)]
    Parsing(#[from] ParsingError),
    #[error("[line {}] Runtime error: {}", .0.line(), .0)]
    Runtime(#[from] RuntimeError),
    #[error("[line {}] Resolving error: {}", .0.line(), .0)]
    Resolving(#[from] ResolvingError),
}

Con esto encontrar errores o bugs en los programas escritos en lox se hace mucho mas sencillo.

cargo run -- programa.lox 
[line 71] Parsing error: Expected `;` after variable declaration, got `var` instead

Sobre el resolver

Al momento de implementar el resolver, en particular, al necesitar hacerle saber al intérprete que para cierta expresión de una variable era necesario buscarla a una x cantidad de saltos de distancia, nos encontramos con otro "problema".

Tanto en Java como en Python, la instancia de la expresión puede usarse como clave y es diferente a otra expresión de la misma variable, cada instancia del objeto tiene una identidad propia. En Rust esto no es así, no hay una forma sencilla de diferenciar dos structs que tengan el mismo contenido. Puede tratar de usarse sus direcciones de memoria, pero esto, en caso de algun movimiento/copia/realloc puede volverse inestable. Otras opciones sería diferenciarlas según el token (por ejemplo, el del nombre o keyword que genera la expresión), pero esto implicaría agregar mas información sobre el token para evitar ambiguedades (si solo nos quedamos con la linea, dos expresiones de la misma variable en la misma linea son indistinguibles).

La decisión que tomamos en este caso es asignar un id único a aquellas expresiones que necesitan ser resueltas localmente (assign, variable, this y super).

src/expressions.rs

pub type ResolverId = usize;

pub enum Expression {
    Var {
        name: Token,
        id: ResolverId,
    },
    Assignment {
        name: Token,
        value: Box<Expression>,
        id: ResolverId,
    },
    Super {
        keyword: Token,
        method: Token,
        id: ResolverId,
    },
    This {
        keyword: Token,
        id: ResolverId,
    },
    ...
}

El encargado de asignar este id es el parser al momento de generarlas, y es continuo a lo largo de toda una sesión de REPL, o parseo de un archivo.

src/parser.rs

// Al encontrarse un Identifier parseando un literal
TokenType::Identifier(_) => Expression::Var {
    id: self.next_resolver_id(),
    name: token,
},

src/parser.rs

// o un this
TokenType::This => Expression::This {
    id: self.next_resolver_id(),
    keyword: token,
},

src/parser.rs

// cuando aparece un "=" con un Var antes (assignment):
if !self.match_next(&[TokenType::Equal]) {
    return Ok(expr);
}

let value = Box::new(self.assignment()?);
if let Expression::Var { id, name } = expr {
    Ok(Expression::Assignment {
        // Desaparece la variable expression, pero nos quedamos con su id para el
        // assignment
        // generar uno nuevo no estaria mal pero quedaría inutilizado el anterior
        id,
        name,
        value,
    })
}

Con los ids únicos generados, ya tenemos una forma de identificar expresiones que tienen que resolverse localmente, por lo que este id es usado como key en el map de locales del intérprete.

src/interpreter.rs

pub struct Interpreter {
    ..,
    locals: HashMap<ResolverId, usize>,
}

src/interpreter.rs

// Al momento de hacer un lookup, obtenemos e indexamos por ese id
fn lookup_variable(.., expression: &Expression) -> .. {
    if let Some(depth) = self.locals.get(&expression.resolve_id()) {
        // get at distance
    } else {
        // get global
    }
}

Usos invalidos de keywords

Siguiendo partes del libro, agregamos en el resolver verificaciones sobre el uso de ciertas keywords en lugares invalidos. Por ejemplo, no puede utilizarse un return fuera de una función, this fuera de un método, etc.

Por ejemplo, para el return:

src/resolver.rs

Statement::Return { value, keyword } => {
    if self.current_function.is_none() {
        return Err(ResolvingError::TopLevelReturn(keyword.clone()));
    }
    ...
}
> return 0;
[line 1] Resolving error: Can't return from top-level code

> this;
[line 1] Resolving error: Can't use `this` outside of a class

Programación Orientada a Objetos

Rulox tiene soporte para el uso de clases, instancias, métodos y herencia. Para su implementación nos basamos principalmente en el libro, manteniendo sus diferencias por el uso de Rust y manejo de referencias como se explicó anteriormente.

La sintaxis para la definición de clases es la siguiente:

class MiClase {
    init(a, b) {
        this.a = a;
        this.b = b;
    }

    metodo() {
        print this.a + this.b;
    }
}

class SubClase < MiClase {
    metodo() {
        super.metodo();
        print "Soy un método de SubClase";
    }
}

// Si no se sobreescribe el constructor, se hereda
var instancia_subclase = SubClase(a, b);
instancia_subclase.metodo();

El metodo init es el constructor de la clase, y se llama automáticamente al instanciar la clase. El uso de this permite acceder a las propiedades de la instancia. El uso de super permite acceder a los métodos de la clase padre.

Funciones nativas

Principalmente frente a la motivación de implementar arrays, donde sería necesario exponer funciones que no pueden ser escritas en lox, como push, pop, len, implementamos funciones nativas. Para no tener que realizar cambios en el interprete (mas allá de bindear las funciones nativas globales al inicio), hicimos que las funciones nativas mantengan la misma interfaz necesaria que las funciones (ahora llamadas "de usuario"), luego solo fue necesario implementar un dispatch sobre estos métodos "públicos".

src/function.rs

pub enum Function {
    User(UserFunction),
    Native(NativeFunction),
}

impl Function {
    ..

    pub fn arity(&self) -> usize {
        match self {
            Function::User(user_func) => user_func.arity(),
            Function::Native(native_func) => native_func.parameters.len(),
        }
    }

    ..
}

Una función nativa no es mas que un wrapper sobre una función de rust. Para tener cierta uniformidad con las funciones de usuario, mantuvimos los parámetros nombrados.

src/native_function.rs

type NativeFn = fn(&mut Interpreter, Vec<Object>, Option<Object>) -> Result<Object, RuntimeError>;

#[derive(Debug, Clone)]
pub struct NativeFunction {
    pub name: String,
    pub parameters: Vec<String>,
    pub func: NativeFn,
    pub receiver: Option<Object>, // En array veremos la necesidad del "receiver"
}

// Ejemplo, función len:
NativeFunction::new("len", &["object"], |_, args, _| match &args[0] {
    Object::String(s) => Ok(Object::Number(s.chars().count() as f64)),
    Object::Array(a) => Ok(a.borrow().len()),
    _ => Err(RuntimeError::NativeFunction(
        "Only strings and arrays have len".into(),
    )),
})

Gracias a los parametros nombrados, y agregando el builtin para que el usuario pueda diferenciarlas, logramos cierta uniformidad:

> fun mi_funcion(a, b) {}
> print mi_funcion;
<fn mi_funcion(a, b)>
> print len;
<builtin fn len(object)>

Las funciones nativas que agregamos exponen funcionalidades que no serían implementables en rulox puro. Por ej:

  • Sobre archivos: read_file(path) y write_file(path, content)
  • Sobre entrada estandar: input()
  • Sobre tiempo: clock() y sleep(seconds)
  • Casteo a string: string(object) (para por ejemplo interpolar un string con un número, obtener la representación de una clase, instancia).

Notar que no es necesaria una función nativa para pasar de un string a un número (ver función to_int en examples/map_reducer.lox). En cambio, string(object) sí lo es, ya que permite obtener una representación en string de cualquier objeto, incluyendo instancias y otros tipos cuya conversión no podría implementarse en rulox puro.

Arrays

Uno de los principales faltantes que sentimos en lox fueron los arrays. Y es que tampoco hay forma de, usando lox, llegar a algo que los reemplace realmente. Sí, puede implementarse una lista enlazada, pero se pierde la mayor ventaja de los arrays, su performance.

Diseño

En este punto, no teníamos referencia sobre el libro, que tampoco los implementa, por lo que empezamos por pensar que sintáxis encajaría con lox, llegando a una simple y estandar:

fun suma(a, b) {}

// Creación con [ y ], permitiendo expresiones como elementos
var array = [10 + 5, nil, "hola", suma];

// Acceso y asignacion de un elemento por indice, 
// aceptando cualquier expresión que evalúe a un entero positivo
var cuarto_elemento = array[3];
array[len(array) - 1] = "otro";

// recorrer un array
for (var i = 0; i < len(array); i = i + 1) {
    print(array[i]);
}

// Push para agregar un elemento al final, como método del array
array.push(5);

// Pop para quitar un elemento del final, y que lo devuelva
var borrado = array.pop();

Decidimos además que asignar un array a una variable haría que esta apunte al mismo array, al igual que la mayoría de los lenguajes productivos (los arrays se pasan por referencia). Ejemplo:

var array = [];
var mismo = array;
mismo.push(2);

print array;
// [2]

print mismo[0];
// 2

Como última decisión, respecto a la igualdad de dos arrays, fuimos por una "igualdad estructural" más que identidad. Dos arrays son iguales si cada uno de sus elementos son iguales.

var un_array = [1, true, nil];
var mismo = un_array;

print un_array == mismo; // true

print un_array == [1, true, nil]; // true

Implementación

La implementación en las etapas de scanning, parsing y resolver no implicaron grandes complicaciones. Se agregaron las expresiones:

src/expressions.rs

pub enum Expression {
    ..,
    Array {
        elements: Vec<Expression>,
    },
    GetIndex {
        left_bracket: Token, // para poder mostrar linea en caso de error
        target: Box<Expression>,
        index: Box<Expression>,
    },
    SetIndex {
        left_bracket: Token,
        target: Box<Expression>,
        index: Box<Expression>,
        value: Box<Expression>,
    },
}

Para parsearlas, agregamos a primary el parseo de Expression::Array al encontrar un [, que parsea expresiones hasta encontrar el cierre (src/parser.rs). En la regla call, agregamos el parseo de GetIndex al encontrar un [ (src/parser.rs). Y en assignment, el parseo de SetIndex al encontrar un = luego de un GetIndex src/parser.rs.

Para el runtime agregamos un nuevo tipo de Object, array, con Rc y RefCell para mantener distintas referencias mutables (por ej, multiples variables apuntando al mismo array):

src/object.rs

pub enum Object {
    ..,
    Array(Rc<RefCell<Array>>),
    ..
}

src/array.rs

pub struct Array {
    elements: Vec<Object>,
}

Para poder implementar los métodos fue necesario extender las funciones nativas con un receiver, ya que al obtener un método del array (que no implica llamarlo) era necesario bindear el array al método para que cuando se llame tenga acceso a sus elementos. Para resolver esto de la forma mas limpia posible, usamos una idea parecida a la de los métodos de instancia. En estos se bindea la instancia (Object::Instance) al environment como this, para tener luego acceso a la misma cuando es llamado. En este caso, al agregar un receiver a la función nativa, nos guardamos el objeto al que está bindeado (como guardar la instancia), de tal forma que sea accesible cuando es llamada.

src/array.rs

impl Array {
    pub fn get(.., array_ref: Rc<RefCell<Self>>) -> .. {
        let array_method = match name.lexeme.as_ref() {
            "push" => Self::push(array_ref),
            "pop" => Self::pop(array_ref),
            _ => return Err(RuntimeError::UndefinedProperty(name.clone())),
        };

        Ok(..(array_method))
    }

    fn pop(array_ref: Rc<RefCell<Array>>) -> Function {
        Function::Native(NativeFunction::new_with_receiver(
            "pop", // nombre
            &[], // parametros
            |_, _, array| { // implementación (recibe interprete, argumentos, y el receiver)
                ..
                // usamos el receiver (la instancia del array) para hacer el pop
                match array.borrow_mut().elements.pop() {
                    Some(elem) => Ok(elem),
                    None => Err(RuntimeError::NativeFunction(
                        "cannot pop from an empty array".into(),
                    )),
                }
            }, 
            // El Object::Array con la referencia al array 
            // es el receiver que vamos a bindear, para acceder cuando se llame
            Some(Object::Array(array_ref.clone())),
        ))
    }
}

src/function.rs

// El interprete solo llama a call con los argumentos, de forma transparente,
// el dispatch entre user y native está en Function, en caso de ser nativa 
// pasa el receiver (que puede ser Some o None, por ejemplo, len no necesita receiver)
impl Function {
    pub fn call(
        &self,
        interpreter: &mut Interpreter,
        args: Vec<Object>,
    ) -> Result<Object, RuntimeError> {
        match self {
            Function::User(user_func) => user_func.call(interpreter, args),
            Function::Native(native_func) => {
                // receiver como tercer argumento
                (native_func.func)(interpreter, args, native_func.receiver.clone())
            }
        }
    }
}

Esta extensión de la función nativa deja cierta extensibilidad para implementar métodos sobre tipos nativos. El interprete, al momento de tener un acceso a una propiedad, solo necesita llamar al get del struct correspondiente. Pasando la instancia por si necesita ser bindeada.

src/interpreter.rs

Expression::Get { object, name } => {
    let object = self.evaluate(object)?;
    match object {
        Object::Instance(instance) => instance.borrow().get(name, instance.clone()),
        Object::Array(array) => array.borrow().get(name, array.clone()),
        _ => Err(..),
    }
}

Para evaluar las expresiones de acceso por indice, se evalúa el target y el indice, se valida que el indice sea un numero entero, y se matchea el target para validar que sea indexable, para luego obtener/asignar el elemento correspondiente (ver evaluate_get_index y evaluate_set_index).

En este punto, decidimos que además del array, el string también sea indexable, permitiendo obtener un caracter del mismo por índice, pero sin permitir asignar a un indice de un string (decidimos que los strings sean inmutables como en la mayoría de lenguajes productivos).

Escape de strings

Para permitir una manipulación mas flexible de strings en rulox decidimos implementar el escape de strings. Esto implicó cambios en el Scanner al momento de escanear un string:

  • Mientras se escanea, la condición de corte ya no es encontrar un ", sino encontrar uno que no tenga previamente un \ (ver src/scanner.rs)
  • Despues de escanear, procesar los escapes (ver la función unescape_string en src/scanner.rs)

La forma que elegimos es estándar, usada en la mayoría de los lenguajes productivos, mediante el uso del caracter \. Las secuencias válidas que agregamos son:

  • \n: produce un salto de linea
  • \r: produce un carriage return
  • \t: produce una tabulación
  • \": produce una comilla doble. Es la forma de tener comillas dentro de un string sin que se corte su escaneo.
  • \\: produce una contrabarra

Cualquier otra secuencia (\ seguido de un caracter no listado arriba) resulta en un error de escaneo. La idea es evitar que el programador piense que cierta secuencia se va a interpretear de cierta forma cuando no es así.

Algunos ejemplos:

> print "esto es una linea\nesto es otra";
esto es una linea
esto es otra
> print "\ttabulado";
        tabulado
> print "Declarar un string en lox: \nvar mi_string = \"hola mundo\";";
Declarar un string en lox: 
var mi_string = "hola mundo";
> print "secuencia \invalida";
[line 1] Scanning error: invalid escape sequence on `secuencia \invalida`: \i

About

Tree-walking interpreter de Lox hecho en rust, para la materia Lenguajes y Compiladores en FIUBA

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages