Pular para o conteúdo

Especificação §20

Extensões de compilador

language-design.md §20 · 247 linhas · 16 min de leitura

O compilador é um pipeline de passes (seção 22) exposto como biblioteca padrão: lexer, parser, type-checker, lowering e codegen, cada estágio uma unidade de fronteira estável. Uma extensão de compilador é um pedaço de código que adiciona um passe novo ao pipeline, ou estende um existente; é compilada antes de tudo e executada no passe de compilação. É o mecanismo pelo qual a linguagem cresce em capacidade sem o core crescer.

Uma extensão pode adicionar quatro coisas: (i) tipos de arquivo e sua sintaxe (uma gramática própria para uma extensão de arquivo nova), (ii) decoradores (com o codegen que os realiza), (iii) alvos de codegen (emitir JS, WASM, o que for), e (iv) passes de pipeline (uma análise, uma transformação). Tudo aditivo.

A regra inviolável é que extensão é add-only: nenhuma extensão toca o core. Ela não redefine o que fn significa, não muda a semântica de um construto existente, não quebra código que compilava. O valor do core é a estabilidade de décadas; uma extensão que pudesse reescrevê-lo destruiria exatamente isso. A fronteira é absoluta: o core é inviolável, a extensão só acrescenta por cima.

E isso não é regra de review, é propriedade da interface: a API que a extensão consome expõe registrar um passe, um decorador, um tipo de arquivo, um alvo de codegen, e nada que mute o core. Não há função para redefinir fn, reescrever a gramática core ou alterar semântica existente; a redefinição não é proibida, é inexprimível, a régua de sempre (tornar o ruim impossível de escrever, não vigiá-lo). Os efeitos colaterais (ler arquivo, subprocesso) ficam no sandbox de capabilities; o poder de tocar o core simplesmente não existe na API.

Uma extensão roda em tempo de compilação, código arbitrário no seu build, como um proc-macro de Rust ou um plugin de GCC. O risco é o de sempre: extensão comprometida, build comprometido. A resposta é a própria filosofia da linguagem aplicada ao compilador: uma extensão é um sistema, e sua fronteira é o conjunto de capabilities que ela declara. No mk.project:

[extensions.ui]
reads = [".mkoui", ".css", ".d.ts"] // arquivos de entrada (e schemas vendorizados)
writes = ["dist/ui/"] // saídas auxiliares, escopadas
codegen = ["js", "wasm"] // o que emite
subprocess = ["wasm-opt"] // escotilha não-hermética: ferramenta externa madura

reads e writes escopam entrada e saída de arquivo; codegen é o que a extensão produz; subprocess é a única escotilha não-hermética: chamar uma ferramenta externa madura (o wasm-opt do binaryen, um minificador de CSS) em vez de reimplementá-la, declarada justamente porque o build passa a depender de um programa de fora. Não há network: buscar da rede em tempo de build quebra reprodutibilidade (é não-determinístico, não-hermético, e vetor de ataque), e o caso que parece exigi-lo (um schema remoto) se resolve melhor vendorizando o schema e lendo o arquivo local (reads). O build é hermético por design.

E há uma rota para hermeticidade total mesmo com ferramenta externa: vendorizar a ferramenta como WASM e rodá-la dentro do interpretador comptime, determinística, sem binário de host e sem subprocess. O subprocess segue existindo como o furo explícito e declarado (você lê o mk.project e vê que aquele build chama wasm-opt, logo não é totalmente hermético); o WASM no comptime é como fechar o furo. A boa prática de quem publica uma extensão que depende de ferramenta é vendorar ou servir junto um binário de confiança (idealmente WASM), ou exigir no mk.project que o host tenha uma versão confiável e compatível instalada: a extensão declara de qual versão depende, e o build falha cedo se não bater, em vez de cuspir um artefato silenciosamente diferente.

Isso é explicitude por marcação (seção 1) no build: você lê o mk.project e sabe o que cada extensão pode tocar. A declaração é o compromisso de design (e já dá auditoria mesmo sem sandbox); a imposição (o sandbox que bloqueia o não-declarado) endurece com a maturidade do compilador.

Nota: esta lista é um esboço. As capabilities acima (reads/writes/codegen/subprocess) e seus valores são provisórios, não a especificação final. O conjunto completo de capacidades, os nomes exatos e todos os valores possíveis só se fecham na implementação, quando o pipeline real expõe suas extensões e se descobre o que uma extensão de fato precisa declarar. O que está fixo é o princípio (add-only, capability-declared, hermético salvo subprocess); a tabela é um ponto de partida.

Extensões são explícitas, nunca ocultas: ligadas por flag (mk build --ext=ui) ou fixas no mk.project. Nenhuma extensão entra sozinha por uma dependência transitiva (o problema dos proc-macros), porque o conjunto de um build está sempre escrito, em um lugar. Podem ser oficiais (mantidas com a linguagem) ou de terceiros (qualquer um escreve), distribuídas como qualquer pacote. Como o compilador é o pipeline exposto como biblioteca, escrever uma extensão é escrever código contra essa biblioteca: registrar um passe, um decorador, um alvo.

A UI é a primeira extensão oficial, e é o exemplo de por que o mecanismo existe. Ela adiciona um tipo de arquivo (.mkoui, com sua gramática de markup), um conjunto de decoradores (@state/@derived/@effect/@component), e alvos de codegen (JS no cliente, WASM, SSR), e fornece o codegen que os realiza. Arquivos .mko são o core (gramática limpa, sem markup); arquivos .mkoui ligam a gramática estendida da extensão. A extensão de arquivo é o opt-in (seção 1): markup nunca polui o core (um .mko jamais vê uma <div>), e quem não faz UI nem encosta.

A UI é extensão, não core, justamente porque a parte volátil dela é volátil. O modelo de reatividade (granularidade fina estilo SolidJS, diff direto sobre o DOM, sem Virtual DOM) é um paradigma de 2026, e paradigmas reativos viram a cada poucos anos. Isolá-lo numa extensão é o que permite ele envelhecer e ser substituído sem tocar o core: se a web mudar de modelo, troca-se a extensão, e nenhum .mko percebe. O que é estável (a fronteira servidor/cliente, que é o channel de rede da seção 4, o alvo HTML/CSS, o @jsimport) sustenta a aposta; o que é volátil (a reatividade) fica numa caixa que pode virar. A aposta continua, agora com o nome certo (extensão) e o lugar certo (fora do core).

O @jsimport é parte desta extensão, não do core: importar tipos de .d.ts e interoperar com JS é território da extensão UI. O @cimport e o inline assembly (seções 17 e 18) ficam no core, porque são FFI estável, de fronteira baixa, que não muda; a régua é exatamente “o estável fica, o volátil vira extensão”.

O modelo de reatividade é de granularidade fina, estilo SolidJS: cada pedaço de estado sabe exatamente quais partes da UI dependem dele, e só essas atualizam quando ele muda. Não há re-execução do componente inteiro (o modelo do React), e não há Virtual DOM: quando um estado muda, a atualização vai direto pro pedaço de DOM afetado. No cliente, isso é diff direto sobre o DOM real do browser (você compara contra o DOM que existe, não contra uma árvore virtual em memória).

A reatividade é expressa por decorators, não keywords (no lugar dos “keywords canônicos” registrados antes):

@state count := 0 // estado reativo: mudou → re-renderiza quem depende
@derived doubled := count * 2 // computado: recomputa quando 'count' muda
@effect { log("count = {{count}}") } // efeito: roda quando suas dependências mudam

Cada decorator vira o construto do core mais a plumbing reativa que o compilador injeta: @state é variável mutável mais rastreamento de dependentes; @derived é variável mais recomputação automática; @effect é channel mais disparo quando as dependências mudam; @context é struct mais propagação pela árvore de componentes. Sem os decorators, você teria os construtos crus (variável, channel) sem a maquinaria, e o decorator é o que liga a reatividade. (O := num @state/@derived é o binding reativo, mutável por construção, já que reatividade exige mutação; você não escreve var porque o decorator já o implica.)

Um provide de @context (@context theme := make_theme()) é chaveado pelo tipo nominal do valor provido; um consumidor o recebe por um parâmetro marcado @context (fn Leaf(theme: Theme @context)), injetado da árvore ambiente em vez de passado como prop. Um consumidor T @context sem nenhum provider de T em lugar nenhum do programa é ERRO DE COMPILAÇÃO — uma dependência insatisfeita, exatamente como uma prop obrigatória faltando — não um render vazio silencioso. Um provider em qualquer componente satisfaz a dependência (a árvore é dinâmica, então o check é presença-de-um-provider, não uma prova estática de ancestralidade).

Um componente é uma função que retorna markup, e os props são os parâmetros: nada de conceito novo, é a função de sempre com markup no return {} (a linguagem não tem return implícito; markup é o conteúdo de um return block):

@component
fn Greeting(name: string, count: int) {
return {
<div class="card">
<h1>Olá, {{name}}</h1>
<p>Você tem {{count}} mensagens</p>
</div>
}
}

@component marca a função; o return {} carrega o markup; {{expr}} interpola texto (a interpolação das strings, seção 16). Usar é instanciar, passando props: <Greeting name="Ana" count={5} />.

O binding no markup tem duas formas, por posição. {{ }} interpola conteúdo (texto via Display, ou um slot/markup) entre tags; { } liga uma expressão (valor, ou closure) depois de um =, seja prop, atributo dinâmico ou handler. Regra de bolso: conteúdo entre tags é {{ }}; valor depois de = é { }. Sem sobreposição: count={5} passa o int, <p>{{count}}</p> exibe o texto, onclick={fn} liga a closure.

Um layout é UI que envolve várias páginas (header, nav, footer persistentes), só um componente de um tipo específico, @component(layout), cujos parâmetros são os slots (onde o conteúdo das páginas entra):

@component(layout)
fn MainLayout(content: slot) {
return {
<header><nav>...</nav></header>
<main>{{content}}</main> // o slot: conteúdo, então {{ }}
<footer>...</footer>
}
}

O slot é o “buraco” onde o conteúdo filho entra (o children do React). Vários slots, vários params. Roteamento (ligar layouts a URLs) não é parte da extensão; é lib e convenção por cima (pra permitir file-based estilo Next ou explícito estilo TanStack), porque é decisão de framework, não de linguagem.

Código de UI roda em dois lugares (servidor e cliente). Em vez de marcar todo código com onde roda, você marca só o que é preso a um lado, e o resto atravessa por padrão:

@server fn load_user(id: int) -> User { return db.query(...) } // só servidor: nunca vai pro cliente
@client fn on_scroll() { ... } // só cliente: roda no browser (WASM)
fn format_date(d: Date) -> string { ... } // sem marcador: roda onde for chamada
  • @server: restrição “só pode rodar no servidor” (depende de banco, segredos, filesystem). Nunca é enviado ao cliente.
  • @client: restrição “só pode rodar no cliente” (manipula DOM, responde a eventos). Vai pro browser como WASM.
  • Sem marcador: função normal, sem restrição de lado. O compilador a compila pra onde for chamada (do servidor vira servidor, do cliente vira cliente, dos dois vira ambos). Lógica pura (formatação, validação) é isto: escreve uma vez, atravessa sem cerimônia.

@server e @client são restrições, não marcadores obrigatórios: você anota só o que é genuinamente preso a um lado, e o comum atravessa (como pub/priv, em que o caso restrito é o anotado). A restrição propaga pra cima: uma função sem marcador que chama algo @server vira server-only (depende de banco, logo não pode rodar no cliente), o compilador infere isso e te barra se você tentar chamá-la do @client. E se ela chama tanto @server quanto @client, ficaria presa aos dois lados ao mesmo tempo, sem lado válido onde rodar, o que é erro de compilação (a propagação a leva a um conjunto impossível). A divisão importa por segurança (código @server com segredos nunca vaza) e por tamanho (só o necessário vira WASM).

O modelo de execução é ilhas (islands), não hidratação de página inteira. A maior parte de uma página é HTML estático renderizado no servidor (texto, layout, conteúdo que não reage). Só os pedaços interativos, as “ilhas”, viram WASM no cliente, carregando apenas a reatividade daquela ilha. O resto fica morto (HTML puro, zero WASM).

Isso evita o custo do React (re-executar e hidratar a página toda no cliente): você manda pro browser só a reatividade das partes que têm reatividade. Uma página com header estático, artigo estático e um botão “curtir” interativo manda HTML morto pro header e o artigo, e uma ilha WASM minúscula só pro botão. (Por isso hidratação completa e a resumability do Qwik ficam de fora: hidratar uma ilha pequena é barato, então a maquinaria pesada de serializar o grafo inteiro não se paga aqui.)

E o estado híbrido cai do @server/@client: um @state numa ilha @client vive no cliente (local, instantâneo, sem round-trip, como um contador que conta no browser); um @state em @server vive no servidor (muda lá, e o servidor manda o fragmento de HTML atualizado). O estado mora onde está declarado: o efêmero de UI (dropdown aberto?) fica local na ilha; o estado de domínio (dados, sessão) fica no servidor. Não há um modelo global de “onde o estado vive”; cada @state vive no seu contexto.

A fronteira servidor/cliente é um channel de rede

Seção intitulada “A fronteira servidor/cliente é um channel de rede”

Quando uma ilha no cliente precisa falar com o servidor (buscar dados, mandar uma ação), a comunicação é um channel de rede (seção 4), não um mecanismo novo de UI. Servidor e cliente são dois mundos isolados, e conversam pelo Channel[T] de sempre, obtido por uma operação de rede que exige T + Serializable e pode falhar:

@client
fn LikeButton(post_id: int) {
@state liked := false
@derived icon := liked ? "♥" : "♡" // ternário: escolha binária
fn handle_click() {
chan := net.connect[LikeAction]("/api/like") catch |e| { return }
chan <- LikeAction{ post: post_id }
reply := -> chan timeout(3s) catch |e| { return } // receive com timeout/catch (seção 4)
liked = reply.ok
}
return {
<button onclick={handle_click}>{{icon}}</button> // handler: { } ; texto: {{ }}
}
}

A ilha do cliente trata o servidor como um processo remoto: manda uma mensagem, espera resposta, com catch e timeout porque a rede pode falhar. É o teu modelo de concorrência inteiro (mundos isolados mais channels) estendido à fronteira servidor/cliente: o mesmo catch, o mesmo timeout, a mesma forma. Nenhum conceito de UI novo pra comunicação, só o channel de rede que já existe.

CSS mora junto do componente: você escreve <style> no markup, ou usa classes diretas (class="card"). Por padrão, o estilo é escopado ao componente, porque o compilador faz hashing de classe, reescrevendo .card pra um nome único (.card_a3f8) e ajustando o markup junto, então o .card de um componente nunca colide com o .card de outro:

@component
fn Card(title: string) {
return {
<div class="card">
<h2 class="title">{{title}}</h2>
</div>
<style>
.card { padding: 1rem; border-radius: 8px; }
.title { font-weight: 600; }
</style>
}
}
// '.card' e '.title' viram '.card_a3f8' / '.title_a3f8', isolados deste componente

As classes são verificadas. Como o compilador vê o <style> e o markup no mesmo componente, ele confere que todo class="X" corresponde a um .X definido: class="crad" (typo) é erro de compilação, não um estilo que silenciosamente não aplica. É o que CSS Modules tipado tenta com gambiarra em TS; aqui é nativo, porque o compilador já tem as duas metades.

Pra estilo que deve vazar (um reset, um tema, estilizar uma lib), a válvula de escape é @scope(global), que desliga o hashing naquele bloco, e os seletores valem globais:

@scope(global)
<style>
:root { --brand: #0a7; } // variáveis globais, não escopadas
body { margin: 0; }
</style>

CSS externo (Tailwind, um design system, um reset de terceiros) entra via @css("arquivo.css"), e é sempre global, sem hashing: o Tailwind define .flex, .pt-4 com nomes fixos que o teu markup usa (class="flex pt-4"), então hashear essas classes as quebraria (não casariam com o class="flex" que você escreve). Por isso CSS importado preserva os nomes:

@css("tailwind.css") // classes globais, nomes preservados
<div class="flex pt-4">...</div> // usa as classes do Tailwind

E essas classes externas entram na verificação: o compilador lê o .css (que já vai processar de qualquer jeito), então class="flex" é válido e class="flexx" (typo) ainda é erro. Você ganha typo-checking das classes do Tailwind, que nem o Tailwind tem. O único limite é que não dá pra escopar o que veio de fora (os nomes são fixos por definição).

Estilo dinâmico usa o binding { }: <div class={cls}> pega a classe de uma expressão. E style={...} é uma struct de structs, CSS-in-JS mas tipado: você monta o estilo como dado da linguagem (campos verificados, valores reativos), em vez de string solta:

@state active := false
<div style={ {background: active ? "#0a7" : "#ccc", padding: "1rem"} }>...</div>

O processamento do CSS é configurável (optimize/none): optimize minifica e remove o não-usado; none passa o CSS como está (além do hashing de escopo, que é estrutural).

CSR roda como WASM no browser, e WASM não fala com o DOM diretamente, passa por JS. Então a linguagem precisa chamar JS, e o mecanismo é o @jsimport, análogo ao @cimport (seção 17): traz JS pra dentro como um módulo, tudo qualificado sob js.. A ponte WASMJS é o próprio @jsimport: o compilador gera a cola (o glue do wasm-bindgen) automaticamente, você não escreve ponte nenhuma. O DOM, fetch e scrollY são só APIs do browser, importadas como qualquer JS:

@jsimport("dom", browser) // a API do DOM, marcada browser-only
@jsimport("react") // lib de terceiros (default: roda em ambos)
el := js.dom.getElementById("app") // tudo sob 'js.', glue WASM↔JS gerado pelo compilador
y := js.window.scrollY

Os tipos vêm em cascata (JS é dinâmico; o C tinha header, JS não tem tipo nativo):

  1. .d.ts automático (default): a maior parte do ecossistema tem tipos em .d.ts, e o compilador lê e gera a interface tipada, como o @cimport lê um .h.
  2. Declaração manual: quando não há .d.ts, você declara as assinaturas do que importa.
  3. Dynamic: chamada sem tipos (js.call(...)), marcada quando você quer, ou como fallback automático por símbolo. Se um símbolo do .d.ts usa um tipo TS que a linguagem não exprime (conditional types, unions exóticas), aquele símbolo cai pra dynamic com um warning, e a lib fica tipada onde dá, dynamic onde não dá. Prioriza compatibilidade (nada trava por um tipo exótico) com visibilidade (o warn marca onde você está sem checagem, resolvível usando dynamic de propósito).

O ambiente é declarado e propaga a restrição. @jsimport("dom", browser) marca o import como browser-only; @jsimport("fs", node) como server-only; terceiros sem marca rodam em ambos. A restrição propaga como @server/@client (acima): chamar js.dom de código que roda no servidor é erro de compilação, não o window is not defined que o JS descobre em runtime. Você troca o typeof window !== "undefined" (runtime, quebra se errar) por uma garantia de compile.

Marshalling reusa Serializable (seção 4). Passar um valor WASMJS é o mesmo problema de cruzar a rede: heaps diferentes, então o que cruza precisa virar bytes. Números passam direto (mesma representação); strings e structs marshalam (cópia ou conversão), e o compilador exige Serializable no que cruza, o mesmo conceito do channel de rede, não um marshalling separado.

E o @jsimport roda nos dois lados: browser (CSR) e runtimes JS de servidor (Node/Deno/Bun). Isso é deliberado pra adoção: a linguagem se encaixa no ecossistema JS existente (você importa libs JS que já usa), reduzindo a resistência de migração.

CSR roda em WASM, que funciona em praticamente todo browser moderno. Pros raros ambientes onde WASM não roda, a extensão transpila pra JavaScript, mas só o .mkoui, não a linguagem inteira. Transpilar a linguagem toda pra JS seria scope-creep enorme (um segundo backend completo, com features sem equivalente JS como @mm, ponteiros e o modelo de processos); o que se transpila é o suficiente pra UI funcionar sem WASM.

É um build explícito (--target=js), opt-in: por padrão você compila pra WASM; só quem precisa do alvo JS o pede, e quem não precisa não recebe nem o custo nem o caminho. E a paridade é total: reatividade fina, ilhas e componentes funcionam idêntico em JS e WASM, e o alvo JS não é uma versão degradada, é o mesmo comportamento por outro backend.

Markup repetido uma vez por elemento de uma coleção é escrito com o mesmo loop do core, como filho de markup:

@component
fn List(xs: []int) {
return { <ul>loop x in xs { <li>{{x}}</li> }</ul> }
}

loop <var> in <iterável> { <corpo> } renderiza <corpo> uma vez por elemento, com <var> ligado a ele (uma list, slice ou array); os pedaços concatenam em ordem. O { depois do iterável abre o corpo do loop, nunca um struct-literal — o iterável é uma expressão de cláusula-de-controle, como no loop do core. Não é um construto novo: é o loop da linguagem reusado em posição de conteúdo, então nada específico de UI é aprendido. A forma dual, funcional, é uma expressão comum num buraco {{ }} ({{ xs.map(fn(x: int) => ...) }}); ambas expressam a mesma iteração, escolhidas por gosto. Sob o target JS o loop baixa para (xs).map((x) => …).join(""), byte-idêntico ao render WASM/SSR.

Roteamento e virtualization: biblioteca, não core

Seção intitulada “Roteamento e virtualization: biblioteca, não core”

Duas coisas parecem de UI mas são lib e convenção sobre a extensão, não parte da linguagem.

Roteamento (ligar URLs a componentes e layouts) é biblioteca, e de propósito, pra permitir tanto file-based (rotas inferidas da estrutura de arquivos, estilo Next) quanto explícito (rotas declaradas em código, estilo TanStack). O core não expõe gancho pra isso; tudo que um roteador precisa (renderizar um componente, ler a URL via @jsimport) já existe.

Virtualization (renderizar só os itens visíveis de uma lista enorme, reciclando nós) também é biblioteca, construível com @state (a posição do scroll), o @jsimport do browser (o scrollY real e as medidas dos elementos vêm da API do browser, importada como qualquer JS) e markup condicional (renderiza a janela visível). O core não precisa de primitiva nenhuma; é um padrão sobre o que já tem.