Ferramentas
O compilador é um processador de queries
Seção intitulada “O compilador é um processador de queries”A decisão fundacional, da qual o resto do tooling cai, é que o compilador não é um batch (parse, checa, emite, de uma vez, e morre), é uma biblioteca incremental de queries, no modelo do rust-analyzer. A razão é que quase toda ferramenta é um consumidor do entendimento que o compilador tem do código: o LSP pergunta “qual o tipo de X?” e “onde Y é definido?”; o linter pede a info semântica; o doc generator pede as assinaturas; o migration tool reescreve a AST. Se cada ferramenta re-implementasse o frontend, seriam seis parsers divergentes. Em vez disso, o compilador é o motor, e as ferramentas são frontends finos sobre ele, coerente com a decisão de binário único (nada de gopls/zls à parte).
A incrementalidade é no nível de declaração (o escopo mais fino): quando você edita uma função, o compilador re-checa aquela declaração e o que depende dela, não o arquivo nem o projeto. É o que faz o LSP responder a cada tecla sem reprocessar tudo. Esse motor é a fundação de LSP, linter, formatter, doc gen e migração: todos perguntam ao mesmo cérebro.
Build, dependências e tarefas
Seção intitulada “Build, dependências e tarefas”O gerenciador de pacotes e o registry são nossos, sem depender de npm nem de crates. A mecânica é estilo Go: cada mk.mod (lib) e o mk.project (bin) declaram suas deps, o mk.sum (seção 15) é o lockfile com os checksums auto-gerados, e o versionamento semver é resolvido por MVS (seleção de versão mínima, reproduzível, sem solver). E o ponto afiado: pacotes são só da linguagem, não embutem C nem JS. Quer C? você o traz (compila e linka, seção 17). Quer JS? vem da npm (o @jsimport lê, seção 20). O registry fica limpo (só a linguagem), sem tentar ser um gerenciador poliglota que reempacota o mundo dos outros.
O build dirige a compilação por alvo via --target: as arquiteturas nativas (o LLVM emite), WASM, e JS (o transpile do .mkoui, seção 20). O C do @cimport compila e linka junto (seção 17); o alvo JS é o transpiler (target JS cospe JS). O backend é LLVM por enquanto (backend próprio depois, seção 18), e o frontend (lexer/parser) é uma camada separada.
O task runner é estilo Turborepo/CMake: tasks são “o que fazer” e suas dependências. Você declara que antes do build roda formatação, lint e testes, e o runner orquestra (o que hoje você escreveria em shell scripts soltos). As tasks moram no mk.project (seção 15), não num arquivo de build à parte.
Dois modos do compilador: portátil e instalado
Seção intitulada “Dois modos do compilador: portátil e instalado”O compilador é um binário único, para transporte trivial (um arquivo no PATH, modo portátil). Mas um binário único é ruim de atualizar (mudou o parser de TS? rebaixaria tudo), então há um segundo modo: mk install host explode o binário num pipeline de N arquivos instalado num diretório, onde mk run/mk build viram a entrada desse pipeline. São os dois modos, e você escolhe: o portátil é o default (um arquivo, zero instalação), o instalado é opt-in para quem quer atualizações incrementais. (Coerente com o teste do opt-in: você não paga a estrutura do pipeline até pedir install host.)
O ganho do pipeline instalado é a atualização parcial: cada estágio (lexer, parser, type-checker, o parser de TS do @jsimport e os demais) é uma unidade versionável de fronteira estável, a mesma disciplina de interfaces desacopladas que a linguagem exige de você (MemoryManager, MemorySource, Serializable) aplicada ao próprio compilador. Então atualizar o parser de TS baixa só aquele estágio, não o compilador inteiro. (Isto pressupõe fronteiras de estágio versionáveis, o que o motor de queries já empurra, já que queries têm interfaces; o pipeline torna esse desacoplamento uma propriedade observável, não só interna.)
E essa estrutura paga em coexistência de versões. Várias versões da linguagem convivem (cada projeto fixa a sua no mk.project, seção 15), e como a versão é um conjunto de estágios, a diferença entre duas versões é o delta do pipeline: versões que compartilham 80% dos estágios baixam só os 20% diferentes. O mesmo vale pro stdlib: uma versão nova baixa o diff, não a biblioteca inteira. Dez projetos em dez versões custam base mais dez deltas pequenos, não dez toolchains inteiras lado a lado (o que rustup e gvm fazem hoje).
Auto-update: checa sempre, aplica quando você manda
Seção intitulada “Auto-update: checa sempre, aplica quando você manda”O compilador checa por updates (default-on), de toolchain e de linguagem (especialmente relevante pré-1.0, mas também depois: lançou a 1.1, ele avisa). Mas a aplicação é opt-in e não-intrusiva: ele não atualiza sozinho, informa (“existe uma versão nova de Y, Z, W; rode X para atualizar”) e você decide. Isso reconcilia com o slow-core (seção 1): o auto-update te avisa da nova versão, não te empurra, e você sobe quando quiser, com o mk.project fixando a versão do projeto até lá. Em CI e build reproduzível, a versão travada do projeto manda, e o aviso nunca muda um build sob seus pés. (A separação é limpa: o toolchain de uma versão recebe patches contínuos; a versão da linguagem é fixa por projeto e sobe por decisão tua, com o migration tool cobrindo as quebras.)
Testes e benchmarks
Seção intitulada “Testes e benchmarks”Testes têm duas formas de morar no projeto: o decorator @test pra testes soltos junto do código (melhor um decorator pra um teste do que um arquivo inteiro), e arquivos dedicados, com file_test.mko ao lado do código pra testes locais, e a pasta tests/ (com sub-módulos e subpastas) pra suíte extensa.
O modo de execução é uma matriz de dois eixos ortogonais, via @testmode(order, state):
order:sequential(em ordem) ouparallel(concorrente).state:isolated(cada teste num processo limpo) oushared(estado carrega entre testes).
Os quatro pontos da matriz pegam classes de bug diferentes: (sequential, isolated) é o comum; (parallel, isolated) é rápido e seguro; (sequential, shared) carrega o estado final de um teste no próximo (left-over transition bugs, uma linha do tempo); e (parallel, shared) é estado concorrente compartilhado, onde moram as race conditions. Atenção ao sentido de “compartilhado”: a linguagem é shared-nothing e previne race de memória (seção 6), então este modo caça race em estado lógico ou externo (um banco, um arquivo, um processo registrado que serve estado), não na memória; “vários testes mexendo no mesmo estado” é esse estado externo e lógico, não um heap compartilhado (que não existe). O default, sem anotação, é o ponto (sequential, isolated), o esperado e seguro. O @testmode é do escopo do arquivo (a matriz se aplica à suíte; o @test solto herda ou usa o default), porque por-teste viraria ingovernável.
Cada posição aceita um valor ou uma lista, e o runner roda o produto cartesiano, na ordem em que os valores aparecem:
@testmode(sequential, [isolated, shared])// roda: (sequential, isolated), depois (sequential, shared)
@testmode([parallel, sequential], isolated)// roda: (parallel, isolated), depois (sequential, isolated)
@testmode([parallel, sequential], [isolated, shared])// roda: (parallel,isolated), (parallel,shared), (sequential,isolated), (sequential,shared)A ordem dos valores na lista é significativa (quer paralelo primeiro? põe na frente). E o report é por combinação: cada resultado é etiquetado com seu par (order, state), porque “teste X falhou” é inútil sem dizer sob qual condição. Um teste pode passar isolado e falhar em (parallel, shared), e isso é exatamente o sinal que você quer.
Custo, e leia isto antes de usar a matriz cheia. A natureza configurável da matriz é poderosa e cara: ela multiplica o tempo da suíte pelo número de combinações. @testmode([parallel, sequential], [isolated, shared]) roda a suíte inteira quatro vezes, ou seja 4x o tempo, o que em CI é significativo. A matriz cheia é uma ferramenta pra caçar um bug específico (uma race que você suspeita, um transition bug que você persegue), não o modo padrão de toda suíte. Use um único ponto no dia a dia (o default (sequential, isolated) já cobre o comum), e abra a matriz quando estiver investigando: é opt-in justamente porque o custo é seu para escolher quando pagar.
Benchmarks seguem o modelo Go (qualquer teste pode ser um benchmark), via @bench. Há duas peças com decisões opostas. O runner é configurável (o esforço: tempo, iterações, repetições, como -benchtime/-count), porque você ajusta quanto rodar; mas o comparador é fixo (a estatística que decide “regressão vs ruído”, variância e significância, inspirada no benchstat do Go). O comparador não é configurável de propósito: limiar de significância ajustável convida ao p-hacking (abaixar o threshold até a regressão “sumir”). Você controla o esforço da medição, não a régua que julga o resultado, coerente com o ethos de um jeito certo.
O linter é fechado (estilo Go): um conjunto de regras que você não estende. Você pode desligá-lo, mas não é recomendado. O ponto do linter fechado, junto com o formatter, é acabar com a format-war (tabs vs espaços, posição de chaves, todas as brigas estéreis). Você programa do jeito que quiser; a linguagem normaliza.
Formatter, LSP e documentação
Seção intitulada “Formatter, LSP e documentação”O formatter é zero-config (estilo gofmt): um único formato canônico, sem opções. Não há .prettierrc, não há bikeshedding de estilo; o código de todo mundo fica igual, e a energia que iria pra discutir formatação vai pro trabalho. É a outra metade do “a linguagem normaliza”.
O LSP se constrói sobre o motor de queries (acima), o que o torna rápido (re-checa por declaração, não o projeto). Entrega o esperado (completion, hover, go-to-definition, rename, diagnostics, refactors) e entende a extensão .mkoui (completar tags, props, ver os bindings).
A documentação usa doc comments com ///, que funcionam tanto na linguagem quanto dentro dos arquivos .mkoui (você documenta um componente onde ele mora). O doc generator renderiza pra três alvos: HTML, Markdown, e um site escrito na própria extensão de UI, em que a extensão se documenta com a extensão (docs como app, já que ela serve pra construir sites).
O migration tool aplica correções mecânicas pra breaking changes (no espírito do go fix e do rustfix): quando o core muda de um jeito que quebra código, o migrador reescreve o teu automaticamente. Mas isto não é só uma ferramenta, é o mecanismo que sustenta uma promessa sobre a linguagem (a política de slow-core, seção 1): mudamos pouco, e quando mudamos, damos o migrador.
Runtime: profiler, observer, debugger
Seção intitulada “Runtime: profiler, observer, debugger”Estas três ferramentas se apoiam quase inteiramente na observabilidade (seção 19), porque o runtime já expõe o que elas consomem.
O profiler mede o que você configurar e expor (seção 19): é opt-in, com captura contínua e tracing ou sampling (e taxa de sampling ajustável). O visualizador não é só flamegraph; além dele, outros gráficos e, deliberadamente, uma saída textual estruturada (na era dos LLMs, um modelo lê o profile como texto e raciocina sobre ele). Os filtros hide_runtime/hide_tracer (seção 19) deixam você focar só no teu projeto, escondendo o ruído do próprio runtime e do tracer.
O runtime observer é o frontend do runtime.processes()/trace() (seção 19), o equivalente ao :observer/dbg do BEAM (de quem herdamos o modelo). A API já existe; o observer a apresenta (árvore de supervisão, processos vivos, channels, o |e| das mortes).
O step-debugger é a única peça de runtime genuinamente nova, porque debugar um sistema de processos isolados não é debugar uma call stack só. Ele tem dois níveis de breakpoint, process-only (pausa só aquele processo, o resto da aplicação segue) e everything (pausa a aplicação inteira, todos os processos), e um sistema de breakpoints linkados: você conecta breakpoints (ABC), e disparar um dispara os outros. Isso é desenhado pro modelo de atores: você liga breakpoints em processos que interagem por um channel e os pega juntos, no momento da interação, exatamente o que um debugger de call-stack única não consegue (ele vê um processo de cada vez, não a interação entre eles).
Tudo isto vale também pra extensão de UI (core/markup)
Seção intitulada “Tudo isto vale também pra extensão de UI (core/markup)”Cada ferramenta tem uma metade core e uma metade markup: o LSP entende .mko e .mkoui, o formatter formata markup, o doc gen documenta componentes, o linter linta markup. A estrutura core/markup atravessa o tooling inteiro: toda decisão acima se duplica pra extensão de UI, porque ela é uma extensão oficial (seção 20), não um anexo.