leaf.rs

Leaf — uma linguagem de script embarcável em Rust

Em construção. A especificação da linguagem está fechada e o plano de implementação está escrito — 27 etapas em 6 fases. O compilador ainda não existe. Esta página é sobre por que a linguagem precisa existir e quais decisões já foram tomadas.

O problema que originou a linguagem

No Rustle, a engine de jogos que estou escrevendo em Rust, criar um componente novo exige recompilar o projeto inteiro.

A causa está numa decisão anterior, e correta. Para integrar componentes ao editor eu escolhi WebAssembly em vez de biblioteca nativa, porque a ABI do Rust não é estável — um plugin compilado com uma versão do compilador pode simplesmente não carregar em outra.

O WebAssembly resolveu isso e trouxe um ganho que não estava no plano: ele carrega o schema junto. O editor inspeciona o módulo, descobre quais campos ele exporta, de que tipo e com quais limites, e monta o painel de propriedades sozinho. Nunca precisei escrever interface para um componente.

O preço foi o ciclo. Toda alteração passa pelo compilador.

Por que não usar uma linguagem pronta

É a pergunta óbvia, e eu não a respondi lendo documentação — respondi construindo. A prova de conceito da engine tem uma camada de script inteira em Rune, com ciclo de vida completo, schema para o editor e módulos carregados sem recompilar. Funcionou. E foi usando que os limites apareceram.

Um módulo em Rune não pode segurar uma referência à cena. A consequência é que ele não executa ações no mundo: ele retorna uma lista de efeitos — despawn, emit — que o hospedeiro traduz e aplica. Também não dá para registrar tipos Rust próprios na VM, então tudo atravessa a fronteira como dado genérico. E o próprio README que escrevi na época já avisava para deixar o trabalho pesado no núcleo nativo.

Nada disso é falha do Rune. São consequências de um projeto que não foi feito para esse uso. O mesmo vale para Lua e Rhai, que também avaliei.

Os cinco requisitos

Escrever a minha deixou de ser capricho quando ficou claro que eu precisava das cinco coisas ao mesmo tempo:

  • Tipagem estática — requisito, não preferência estética
  • Compilada para uma VM, não interpretada a partir da árvore sintática
  • Integração nativa com Rust, sem marshalling de dados na fronteira
  • Sem coletor de lixo — pausa imprevisível é inaceitável num loop de frame
  • Sem panic na VM — um script mal escrito não pode derrubar o editor

Cada linguagem que avaliei atende algumas. Nenhuma atende todas.

Como Leaf se parece

@implements(Atualizavel)

let vida: int = 100
let tempo: float = 0.0

def atualizar(self, dt: float) -> void:
	self.tempo = self.tempo + dt
	if self.vida <= 0:
		console.log("morreu")

def dano(self, v: int) -> void:
	self.vida = self.vida - v

A sintaxe é próxima da de Python — def, dois-pontos, blocos por indentação — só que com o tipo de tudo escrito explicitamente. Não há modificador nenhum antes do def: o que decide se uma função acessa o estado da instância é a presença de self. A linha que começa com @ é uma anotação, e elas merecem seção própria.

O estado de um script

Um script com estado define um tipo implícito, Self, cujos campos são os let declarados no topo do arquivo. Cada entidade do jogo tem uma instância desse tipo, e é ela que a VM passa em toda chamada.

Isso não inventa um conceito novo — a máquina já guardava exatamente isso, um bloco de estado por instância com a arena de memória junto. O self só dá nome, do lado do script, a algo que já existia do lado do Rust. E o efeito é que uma regra só passa a valer nos dois lugares onde antes havia duas: em script e em bloco de implementação de struct, quem declara self acessa estado, quem não declara, não acessa.

Escrever self. também é obrigatório no acesso, e o motivo não é desambiguação — sombreamento já é erro na linguagem. É que campo de self é a única mutação que a linguagem tem: struct, Array, Map e string são todos imutáveis. Deixar a única coisa perigosa parecer atribuição de variável local seria esconder justamente o que precisa ser visto.

Anotações

Uma anotação é um prefixo de declaração, e pode marcar um módulo, uma variável, uma função ou um tipo. Parece detalhe de sintaxe, mas é a peça que resolve três problemas diferentes de uma vez só.

A linguagem tem três embutidas — @implements, @mod_name e @default. Todas as outras são registradas pelo programa que hospeda a VM, com alvo e parâmetros declarados. É assim que uma engine ensina aos scripts os conceitos que só ela conhece:

@export(min=0, max=100, step=1) let vida: int = 100
@export(values=["copas", "paus", "ouro", "espada"]) let naipe: string

Fazem a ponte entre o script e o editor. @export(min=0, max=100, step=1) é uma anotação do Rustle, não do Leaf, e não é decoração: é a descrição do campo que o editor vai desenhar. O tipo da variável decide o widget, os limites decidem a faixa, e values transforma uma string num seletor de opções. É a mesma ideia que faz o editor gerar painéis sozinho a partir dos tipos em Rust, agora disponível para quem escreve script — um componente em Leaf ganha sua interface de propriedades sem que ninguém escreva interface.

Substituem código repetitivo. @implements(Atualizavel) declara que o arquivo inteiro é a implementação de uma interface. Não há bloco de implementação, não há método para encaixar numa estrutura: as funções do script já são os métodos. E a economia não é só de digitação — o compilador transforma isso numa tabela de despacho, então o motor chama o script por índice, sem procurar função por nome em tempo de execução.

Validam. Como as anotações são tipadas, o compilador checa o que elas dizem. min maior que max é erro; step zero é erro; cada elemento de values é verificado contra o tipo da variável. E @implements vira uma promessa que o compilador cobra, listando tudo o que falta de uma vez em vez de parar no primeiro problema:

error: script "inimigo" declara @implements(Atualizavel)
       mas não satisfaz a trait
  --> inimigo.leaf:1:1
  - método faltando: atualizar(self, float) -> void
  - assinatura incorreta: obter_descricao
      esperado:   (self) -> string
      encontrado: (self, int) -> string

Anotação desconhecida é erro de compilação, o oposto do que costuma acontecer com decoradores por aí, onde uma anotação que ninguém trata é silenciosamente ignorada e o erro de digitação só aparece quando o comportamento esperado não acontece. Para metadados que a VM deve mesmo ignorar existe um espaço reservado, @meta.

Sem coletor de lixo, e sem vazamento

Contagem de referências é a forma mais simples de gerenciar memória sem coletor: cada valor sabe quantos o apontam, e some quando ninguém mais aponta. O problema clássico é o ciclo — se A aponta para B e B volta a apontar para A, a contagem de nenhum dos dois chega a zero, e os dois vazam.

A resposta usual é acrescentar um detector de ciclos. Só que um detector de ciclos é um coletor de lixo pequeno, com o mesmo defeito: roda quando quer e para o mundo enquanto roda.

let a: Array<int> = [1, 2, 3]
let b: Array<int> = a.push(4)   // a continua [1, 2, 3]
let c: int? = a.get(10)         // None, nunca panic

Leaf resolve por construção. Array, Map, string e struct são imutáveis — só o vínculo da variável muda, nunca o valor. E um valor imutável não consegue passar a apontar para si mesmo depois de criado, porque para construir o filho o pai já teria que existir. Sem mutação, não há ciclo; sem ciclo, a contagem de referências basta sozinha.

É por isso que a imutabilidade aqui é decisão estrutural, e não gosto pessoal. Ela é o que compra o "sem coletor de lixo" sem pagar em vazamento. E é por isso que quatro restrições que parecem sem relação — struct não pode conter a si mesma, closure com captura não escapa do frame, builder não é valor de primeira classe e Self não é valor de primeira classe — são a mesma decisão vista de quatro lados. Quebre qualquer uma e o ciclo volta.

A VM não entra em pânico

Toda falha vira valor de retorno. Divisão inteira por zero, estouro de índice, overflow — nenhuma derruba o processo; todas sobem como Err para o Rust que chamou, com nome do script, função e linha.

  • Divisão por zero segue o padrão de Rust e C#, sem o pânico: float / 0.0inf, que é IEEE 754 e não é erro; int / 0 vira falha da VM; e dividir por 0 literal é erro de compilação, porque isso é sempre bug.
  • Combustível. Um contador cai a cada volta de laço e a cada chamada. Zerou, a execução para com OutOfFuel. Sem isso, um while true num script travaria o frame inteiro.
  • Release não muda a semântica. Verificação de overflow e de limite de coleção nunca são removidas, nem no modo otimizado. Isso difere de C e de Rust — e é deliberado: em script de jogo, um erro localizado é sempre melhor que um valor errado silencioso.

Por que tipagem estática

Depois de anos mantendo sistemas grandes, a conclusão é difícil de escapar: em projeto longo ou com várias pessoas, a ausência de tipos cobra caro. E não é opinião isolada — linguagens que nasceram sem tipagem construíram mecanismos para adicioná-la. Python ganhou anotações de tipo; JavaScript ganhou o TypeScript. O mercado votou.

Há um segundo motivo, este puramente técnico. Tipos conhecidos em tempo de compilação abrem otimizações que uma linguagem dinâmica não pode fazer: se a VM sabe que um registrador é um inteiro, ela não precisa carregar essa informação em runtime nem verificá-la a cada operação. Em Leaf isso é levado ao limite — a VM nunca pergunta "que tipo é isso?", porque o código gerado já sabe.

E há um terceiro, específico deste caso: o Rustle já usa tipos para gerar a interface do editor. Uma linguagem de script tipada preserva essa propriedade em vez de quebrá-la.

Estado atual

A referência da linguagem está escrita e as decisões estruturais estão fechadas — indentação, resolução de nomes, conversão numérica, igualdade estrutural, layout de cada tipo na VM. O que ficou de fora da primeira versão está listado explicitamente, e a maior parte ficou de fora por projeto, não por prazo: não há try/catch, não há inferência de tipos, não há operador de identidade de referência e não há variável global mutável.

O plano de implementação tem 27 etapas em 6 fases, distribuídas em nove crates. Duas regras o organizam:

  • Esqueleto que anda primeiro. A fase 1 entrega o pipeline completo — fonte, bytecode, execução, retorno ao Rust — suportando apenas int. Tudo depois é acrescentar tipo a uma máquina que já roda. É a defesa contra o erro clássico de escrever parser por três meses sem nunca executar nada.
  • Critério de fim é teste que passa, não "está pronto". Quando não dá para escrever o teste, o critério está mal formulado.

Por que a linguagem antes do motor

A ordem é deliberada. Meu conhecimento de linguagens, estruturas de dados e algoritmos é bem mais sólido que meu conhecimento de computação gráfica. Começar pelo Leaf significa trabalhar em terreno onde consigo julgar minhas próprias decisões — e descobrir até onde levo uma linguagem antes de encarar rasterização e pipeline de renderização.

Competências em destaque

  • Projeto de linguagem: sistema de tipos sem inferência, semântica de valor, escopo de primeira versão definido por decisão e não por prazo
  • Compiladores: lexer, parser, resolução de nomes, verificação de tipos, geração de código para uma VM de registradores
  • Gerência de memória: arena com handle geracional, contagem de referências sem detector de ciclos
  • Rust: separação de estado por quem muda quando, para que a VM seja compartilhável entre threads sem trava

Escrevendo sobre o Leaf

Onde acompanhar

  • rustle.rs — a engine que motivou a linguagem
  • GitHub — o repositório do Leaf ainda não é público