🚀 Árvore Sintática Incremental e Language Server Protocol: Compiladores como Serviços Interativos
Bem-vindo ao Futuro dos Compiladores!
Você chegou ao momento culminante da nossa jornada! Esta semana final representa a transformação mais espetacular que você testemunhará: converter o compilador que você construiu de uma ferramenta batch que processa arquivos completos em um serviço interativo e sempre ativo que fornece feedback instantâneo enquanto você digita.
Prepare-se para descobrir como os compiladores modernos não são apenas processadores de código, mas assistentes inteligentes que trabalham ao seu lado, oferecendo diagnósticos em tempo real, sugestões contextuais, navegação de código e refatorações automáticas. Você aprenderá sobre parsing incremental, Language Server Protocol, e técnicas de integração com editores como Visual Studio Code.
Esta não é apenas mais uma semana de teoria - é o momento onde todo o seu trabalho se materializa em uma experiência de desenvolvimento profissional e moderna que você poderá usar e demonstrar com orgulho! 🎉
🎯 O Que Você Descobrirá Nesta Jornada
⚡ Compilação Incremental
Você compreenderá por que recompilar todo o arquivo a cada modificação é inaceitável para experiências interativas. Descobrirá técnicas sofisticadas de parsing incremental onde apenas as partes modificadas do código são reanalisadas, permitindo feedback instantâneo mesmo em arquivos com milhares de linhas.
Aprenderá sobre estruturas de dados especializadas como árvores sintáticas persistentes e incrementais que podem ser atualizadas eficientemente quando o código muda, mantendo a maior parte da estrutura anterior intacta. Esta eficiência é fundamental para responsividade em editores modernos.
🔌 Language Server Protocol
Dominará o protocolo padronizado que revolucionou ferramentas de linguagem. O LSP permite que qualquer linguagem ofereça suporte de primeira classe em qualquer editor compatível, democratizando experiências de desenvolvimento sofisticadas que antes eram exclusivas de IDEs proprietárias.
Explorará como expor as capacidades do seu compilador através de uma interface JSON-RPC, fornecendo funcionalidades como diagnósticos, autocompletar, navegação para definições, busca de referências, hover com informações de tipo, e quick fixes para correções automáticas de erros comuns.
💡 Do Batch ao Interativo: Uma Mudança de Paradigma
O Problema dos Compiladores Tradicionais
Durante todo o semestre, você construiu um compilador batch que processa arquivos completos. Quando você executa seu compilador, ele lê o arquivo fonte do início ao fim, realiza todas as fases de análise, e produz saída completa - seja código executável ou mensagens de erro. Este modelo funciona perfeitamente para compilação final, mas é fundamentalmente inadequado para desenvolvimento interativo.
⏱️ O Custo da Recompilação Total
Imagine um desenvolvedor trabalhando em um arquivo de mil linhas. Ele adiciona um único caractere e espera feedback sobre se aquela mudança introduziu algum erro. Com compilação batch tradicional, o compilador precisaria:
Reler todo o arquivo do disco (ou da memória), executar análise léxica completa de mil linhas gerando milhares de tokens, realizar parsing completo reconstruindo toda a árvore sintática, refazer análise semântica verificando tipos e escopos em todo o programa, e finalmente reportar diagnósticos.
Para arquivos realmente grandes ou para mudanças frequentes (como digitação normal), este custo se torna inaceitável. O feedback precisa ser instantâneo - idealmente dentro de dezenas de milissegundos - para não interromper o fluxo de pensamento do desenvolvedor.
A experiência do desenvolvedor moderno exige que ferramentas respondam instantaneamente. Quando você digita código em VS Code, TypeScript, ou IntelliJ, espera ver erros destacados imediatamente, autocompletar aparecendo contextualmente, e informações de tipo disponíveis ao passar o mouse. Nenhuma dessas experiências seria possível com recompilação total a cada keystroke.
A Solução: Compilação Incremental
A compilação incremental resolve este problema através de uma observação fundamental sobre como código evolui durante desenvolvimento: a maior parte do código permanece inalterada entre edições. Quando um desenvolvedor adiciona uma linha, apenas aquela região do arquivo foi modificada - o restante permanece idêntico.
Esta observação sugere uma estratégia: em vez de jogar fora toda a análise anterior e recomeçar do zero, podemos reutilizar a maior parte do trabalho já realizado e atualizar apenas as porções afetadas pela mudança. Esta é a essência da compilação incremental.
🎯 Analogia Esclarecedora: Revisão de Documento
Imagine que você está revisando um documento longo. Você identifica um erro de digitação no meio de uma página e o corrige. Seria absurdo reler todo o documento desde o início apenas porque uma palavra mudou - você simplesmente relê aquele parágrafo para confirmar que a correção faz sentido no contexto.
Compilação incremental aplica o mesmo princípio: quando código muda, reanalisamos apenas a região afetada, mantendo o restante da estrutura sintática intacta. Isto requer técnicas sofisticadas para determinar precisamente quais partes foram afetadas e como atualizar eficientemente as estruturas de dados que representam o programa.
A compilação incremental não beneficia apenas a responsividade imediata. Ela também permite experiências mais ricas: editores podem mostrar erros enquanto você digita código incompleto, oferecer sugestões baseadas em contexto parcial, e até prever sua intenção antes de terminar de digitar.
🌳 Árvores Sintáticas Incrementais: Fundamentos Teóricos
Persistência e Compartilhamento Estrutural
A estrutura de dados fundamental para parsing incremental é a árvore sintática persistente. Diferentemente de estruturas mutáveis tradicionais onde modificações sobrescrevem dados anteriores, estruturas persistentes preservam versões anteriores permitindo que múltiplas versões coexistam.
📋 Árvores Sintáticas Persistentes
Uma árvore sintática persistente é uma representação da estrutura hierárquica do código onde:
Imutabilidade: Nós da árvore são imutáveis. Uma vez criados, seus filhos e atributos não mudam.
Compartilhamento estrutural: Quando construímos uma nova versão da árvore após uma edição, compartilhamos todos os nós que não foram afetados pela mudança. Apenas os nós na “coluna vertebral” da mudança são duplicados.
Eficiência temporal: Criar nova versão da árvore tem custo proporcional apenas ao tamanho da mudança, não ao tamanho total da árvore.
Eficiência espacial: Múltiplas versões da árvore compartilham a maior parte de seus nós, resultando em overhead de memória modesto.
Esta propriedade de compartilhamento estrutural é extraordinariamente poderosa. Imagine uma árvore sintática com mil nós. Quando modificamos uma folha profunda, apenas os nós no caminho da raiz até aquela folha precisam ser recriados - talvez vinte nós. Os outros novecentos e oitenta nós são compartilhados entre a versão antiga e nova da árvore.
Estruturas persistentes foram popularizadas em programação funcional onde imutabilidade é um princípio fundamental. Linguagens como Clojure e Haskell usam extensivamente estruturas persistentes para oferecer tanto imutabilidade quanto performance. A aplicação destas ideias a compiladores incrementais foi uma inovação relativamente recente que transformou ferramentas de linguagem modernas.
Algoritmo de Atualização Incremental
O processo de atualizar incrementalmente uma árvore sintática quando o código muda envolve várias etapas cuidadosamente orquestradas. Vamos explorar cada uma detalhadamente.
Detecção de mudanças: O primeiro passo é determinar precisamente o que mudou. Editores fornecem esta informação através de notificações estruturadas que especificam posição inicial, posição final, e texto novo. Por exemplo, “na linha 42, coluna 15, deletar 3 caracteres e inserir ‘nova’”.
Identificação da região afetada: Com base na mudança textual, determinamos quais nós da árvore sintática podem ter sido afetados. Geralmente, começamos no nó folha que contém a posição da edição e subimos na árvore até encontrar um nó cuja estrutura sintática ainda é válida após a mudança.
Reparsing local: A região afetada é reparsada do zero. Isto produz um novo subárvore que substitui a subárvore antiga. O parser precisa ser capaz de começar e terminar parsing em pontos arbitrários da gramática, não apenas no símbolo inicial.
Reconstrução da coluna vertebral: Nós no caminho da raiz até a região modificada são recriados com ponteiros atualizados, mas mantêm ponteiros para subárvores não afetadas. Este processo de “path copying” é o coração do compartilhamento estrutural.
Validação de sincronização: Após reparsing local, verificamos se o parser “resincronizou” corretamente com o código existente após a mudança. Se a mudança introduziu erros sintáticos que se propagam além da região esperada, pode ser necessário estender a região de reparsing.
// Representação de árvore sintática persistente e incremental
class ArvoreIncrementalNode {
final String tipo;
final int posicaoInicio;
final int posicaoFim;
final List<ArvoreIncrementalNode> filhos;
final Map<String, dynamic> atributos;
final int versao; // Rastreamento de versão para debugging
const ArvoreIncrementalNode({
required this.tipo,
required this.posicaoInicio,
required this.posicaoFim,
required this.filhos,
required this.atributos,
required this.versao,
});
// Imutabilidade: criamos nova instância em vez de modificar
ArvoreIncrementalNode comFilhos(List<ArvoreIncrementalNode> novosFilhos) {
return ArvoreIncrementalNode(
tipo: tipo,
posicaoInicio: posicaoInicio,
posicaoFim: posicaoFim,
filhos: novosFilhos,
atributos: atributos,
versao: versao + 1,
);
}
// Verifica se este nó contém uma posição específica
bool contem(int posicao) {
return posicao >= posicaoInicio && posicao <= posicaoFim;
}
}
class ParserIncremental {
ArvoreIncrementalNode? arvoreAtual;
String textoAtual = '';
// Atualiza a árvore incrementalmente quando o texto muda
ArvoreIncrementalNode atualizarIncremental(
int inicioMudanca,
int fimMudanca,
String novoTexto,
) {
// Passo 1: Determinar texto completo após mudança
final textoBefore = textoAtual.substring(0, inicioMudanca);
final textoAfter = textoAtual.substring(fimMudanca);
textoAtual = textoBefore + novoTexto + textoAfter;
// Passo 2: Se não há árvore anterior, fazer parsing completo
if (arvoreAtual == null) {
arvoreAtual = parsearCompleto(textoAtual);
return arvoreAtual!;
}
// Passo 3: Encontrar nó mais alto afetado pela mudança
final noAfetado = encontrarNoAfetado(arvoreAtual!, inicioMudanca);
// Passo 4: Reparsar apenas a região afetada
final inicioReparsing = noAfetado.posicaoInicio;
final fimReparsing = noAfetado.posicaoFim +
(novoTexto.length - (fimMudanca - inicioMudanca));
final regiao = textoAtual.substring(inicioReparsing, fimReparsing);
final novaSubarvore = parsearRegiao(regiao, inicioReparsing);
// Passo 5: Reconstruir coluna vertebral mantendo compartilhamento
arvoreAtual = reconstruirColuna(
arvoreAtual!,
noAfetado,
novaSubarvore,
);
return arvoreAtual!;
}
// Encontra o nó mais alto que contém a mudança
ArvoreIncrementalNode encontrarNoAfetado(
ArvoreIncrementalNode raiz,
int posicao,
) {
if (!raiz.contem(posicao)) {
return raiz; // Mudança está fora desta subárvore
}
// Procurar nos filhos qual contém a posição
for (final filho in raiz.filhos) {
if (filho.contem(posicao)) {
// Recursivamente descer na árvore
return encontrarNoAfetado(filho, posicao);
}
}
// Este nó contém a posição mas nenhum filho contém
return raiz;
}
// Reconstrói a coluna vertebral da árvore
ArvoreIncrementalNode reconstruirColuna(
ArvoreIncrementalNode raiz,
ArvoreIncrementalNode noAntigo,
ArvoreIncrementalNode noNovo,
) {
// Se este nó é o alvo, substituir
if (raiz == noAntigo) {
return noNovo;
}
// Senão, encontrar qual filho contém o nó antigo e reconstruir
final novosFilhos = raiz.filhos.map((filho) {
if (_arvoreContem(filho, noAntigo)) {
return reconstruirColuna(filho, noAntigo, noNovo);
}
return filho; // Compartilhar filho não afetado
}).toList();
// Criar novo nó com filhos atualizados
return raiz.comFilhos(novosFilhos);
}
// Verifica se uma subárvore contém um nó específico
bool _arvoreContem(
ArvoreIncrementalNode arvore,
ArvoreIncrementalNode no,
) {
if (arvore == no) return true;
return arvore.filhos.any((filho) => _arvoreContem(filho, no));
}
// Parser completo (simplificado para ilustração)
ArvoreIncrementalNode parsearCompleto(String texto) {
// Implementação real seria muito mais complexa
return ArvoreIncrementalNode(
tipo: 'Programa',
posicaoInicio: 0,
posicaoFim: texto.length,
filhos: [],
atributos: {},
versao: 0,
);
}
// Parser de região (simplificado)
ArvoreIncrementalNode parsearRegiao(String texto, int offset) {
return ArvoreIncrementalNode(
tipo: 'Fragmento',
posicaoInicio: offset,
posicaoFim: offset + texto.length,
filhos: [],
atributos: {},
versao: 0,
);
}
}
// Exemplo de uso do parser incremental
void demonstrarParsingIncremental() {
final parser = ParserIncremental();
// Texto inicial
parser.atualizarIncremental(0, 0, 'let x = 10;');
print('Árvore após texto inicial criada');
// Adicionar segunda linha
parser.atualizarIncremental(11, 11, '\nlet y = 20;');
print('Árvore atualizada incrementalmente com nova linha');
// Modificar primeira linha
parser.atualizarIncremental(8, 10, '100');
print('Árvore atualizada incrementalmente modificando valor');
// Note: Apenas os nós afetados foram reparsados
// A maior parte da estrutura foi compartilhada entre versões
}#include <memory>
#include <vector>
#include <string>
#include <map>
#include <variant>
// Representação de árvore sintática persistente e incremental
class ArvoreIncrementalNode {
public:
std::string tipo;
int posicaoInicio;
int posicaoFim;
std::vector<std::shared_ptr<ArvoreIncrementalNode>> filhos;
std::map<std::string, std::variant<int, std::string>> atributos;
int versao;
ArvoreIncrementalNode(
const std::string& t,
int inicio,
int fim,
int v = 0
) : tipo(t), posicaoInicio(inicio), posicaoFim(fim), versao(v) {}
// Cria novo nó com filhos atualizados (imutável)
std::shared_ptr<ArvoreIncrementalNode> comFilhos(
const std::vector<std::shared_ptr<ArvoreIncrementalNode>>& novosFilhos
) const {
auto novo = std::make_shared<ArvoreIncrementalNode>(
tipo,
posicaoInicio,
posicaoFim,
versao + 1
);
novo->filhos = novosFilhos;
novo->atributos = atributos;
return novo;
}
// Verifica se este nó contém uma posição
bool contem(int posicao) const {
return posicao >= posicaoInicio && posicao <= posicaoFim;
}
};
class ParserIncremental {
private:
std::shared_ptr<ArvoreIncrementalNode> arvoreAtual;
std::string textoAtual;
// Encontra o nó mais alto que contém a mudança
std::shared_ptr<ArvoreIncrementalNode> encontrarNoAfetado(
std::shared_ptr<ArvoreIncrementalNode> raiz,
int posicao
) {
if (!raiz->contem(posicao)) {
return raiz;
}
for (const auto& filho : raiz->filhos) {
if (filho->contem(posicao)) {
return encontrarNoAfetado(filho, posicao);
}
}
return raiz;
}
// Reconstrói a coluna vertebral mantendo compartilhamento
std::shared_ptr<ArvoreIncrementalNode> reconstruirColuna(
std::shared_ptr<ArvoreIncrementalNode> raiz,
std::shared_ptr<ArvoreIncrementalNode> noAntigo,
std::shared_ptr<ArvoreIncrementalNode> noNovo
) {
if (raiz == noAntigo) {
return noNovo;
}
std::vector<std::shared_ptr<ArvoreIncrementalNode>> novosFilhos;
for (const auto& filho : raiz->filhos) {
if (arvoreContem(filho, noAntigo)) {
novosFilhos.push_back(
reconstruirColuna(filho, noAntigo, noNovo)
);
} else {
novosFilhos.push_back(filho); // Compartilhar
}
}
return raiz->comFilhos(novosFilhos);
}
// Verifica se subárvore contém nó específico
bool arvoreContem(
std::shared_ptr<ArvoreIncrementalNode> arvore,
std::shared_ptr<ArvoreIncrementalNode> no
) const {
if (arvore == no) return true;
for (const auto& filho : arvore->filhos) {
if (arvoreContem(filho, no)) return true;
}
return false;
}
public:
// Atualiza árvore incrementalmente
std::shared_ptr<ArvoreIncrementalNode> atualizarIncremental(
int inicioMudanca,
int fimMudanca,
const std::string& novoTexto
) {
// Atualizar texto completo
std::string textoBefore = textoAtual.substr(0, inicioMudanca);
std::string textoAfter = textoAtual.substr(fimMudanca);
textoAtual = textoBefore + novoTexto + textoAfter;
// Se não há árvore, fazer parsing completo
if (!arvoreAtual) {
arvoreAtual = parsearCompleto(textoAtual);
return arvoreAtual;
}
// Encontrar região afetada
auto noAfetado = encontrarNoAfetado(arvoreAtual, inicioMudanca);
// Reparsar região
int inicioReparsing = noAfetado->posicaoInicio;
int deltaTamanho = novoTexto.length() - (fimMudanca - inicioMudanca);
int fimReparsing = noAfetado->posicaoFim + deltaTamanho;
std::string regiao = textoAtual.substr(
inicioReparsing,
fimReparsing - inicioReparsing
);
auto novaSubarvore = parsearRegiao(regiao, inicioReparsing);
// Reconstruir coluna vertebral
arvoreAtual = reconstruirColuna(
arvoreAtual,
noAfetado,
novaSubarvore
);
return arvoreAtual;
}
// Parsers simplificados para ilustração
std::shared_ptr<ArvoreIncrementalNode> parsearCompleto(
const std::string& texto
) {
return std::make_shared<ArvoreIncrementalNode>(
"Programa",
0,
texto.length()
);
}
std::shared_ptr<ArvoreIncrementalNode> parsearRegiao(
const std::string& texto,
int offset
) {
return std::make_shared<ArvoreIncrementalNode>(
"Fragmento",
offset,
offset + texto.length()
);
}
};Tolerância a Erros e Parsing de Código Incompleto
Um aspecto marcante de parsers incrementais para experiências interativas é a capacidade de lidar graciosamente com código sintaticamente incorreto ou incompleto. Desenvolvedores passam a maior parte do tempo com código em estados parcialmente válidos - durante digitação, durante refatoração, durante debugging.
Parsers tradicionais frequentemente falham completamente quando encontram erros sintáticos, aborta parsing e reportando apenas o primeiro erro. Isto é inaceitável para compiladores interativos que precisam fornecer feedback contínuo mesmo quando código está incorreto.
🛠️ Estratégias de Recuperação de Erros
Inserção de tokens ausentes: Quando o parser espera um token específico que está ausente (como um ponto-e-vírgula), pode inserir sinteticamente aquele token e continuar parsing. O erro é reportado mas parsing prossegue.
Pular até pontos de sincronização: Se o parser encontra sequência de tokens que não faz sentido, pode pular tokens até encontrar um ponto de sincronização conhecido (como início de próxima declaração ou comando). Isto permite recuperar estrutura de alto nível mesmo quando fragmentos individuais têm erros.
Produções de erro: A gramática pode incluir produções especiais que capturam padrões de erro comuns. Por exemplo, uma produção para “expressão com parênteses desbalanceados” permite parsing continuar mesmo quando balanceamento está incorreto.
Análise heurística: Para código severamente malformado, parsers podem usar heurísticas baseadas em indentação, palavras-chave, e padrões estruturais para inferir estrutura provável mesmo sem gramática formal.
A qualidade da recuperação de erros afeta dramaticamente a experiência do desenvolvedor. Parsers com recuperação pobre fornecem apenas informações sobre o primeiro erro, deixando desenvolvedores no escuro sobre problemas subsequentes. Parsers com recuperação excelente identificam múltiplos erros simultaneamente e mantêm estrutura sintática suficiente para análise semântica parcial.
🔌 Language Server Protocol: Democratizando Ferramentas de Linguagem
A História e Motivação do LSP
Antes do Language Server Protocol, cada combinação de linguagem e editor requeria integração específica customizada. Se você quisesse suporte para TypeScript em cinco editores diferentes, precisaria implementar cinco plugins diferentes, cada um comunicando com o compilador TypeScript de forma única.
Esta fragmentação criava barreiras enormes. Linguagens novas ou nichadas raramente tinham suporte em editores populares porque o esforço de implementar múltiplos plugins era proibitivo. Inversamente, editores novos começavam com suporte mínimo de linguagens porque cada linguagem requeria integração customizada.
📖 A Revolução do LSP
Em 2016, Microsoft introduziu o Language Server Protocol como parte do desenvolvimento de Visual Studio Code. A ideia era elegantemente simples: definir um protocolo padronizado de comunicação entre editores (clientes) e ferramentas de linguagem (servidores).
Com LSP, implementar suporte para uma linguagem nova requer apenas um servidor LSP que pode então funcionar em qualquer editor compatível. Inversamente, editores que implementam o protocolo LSP automaticamente ganham suporte para todas as linguagens que oferecem servidores LSP.
Este desacoplamento transformou o ecossistema de ferramentas de linguagem. Linguagens pequenas agora podem oferecer experiências de desenvolvimento sofisticadas implementando um único servidor LSP. Editores novos começam com suporte robusto para dezenas de linguagens imediatamente.
Hoje, LSP é amplamente adotado. Visual Studio Code, Vim/Neovim, Emacs, Sublime Text, Eclipse, Atom, e muitos outros editores suportam LSP. Centenas de linguagens oferecem servidores LSP, desde gigantes como Python, Java e JavaScript até linguagens nichadas e DSLs especializadas.
Arquitetura do LSP: Cliente e Servidor
A arquitetura do LSP é baseada no modelo cliente-servidor com comunicação via JSON-RPC (Remote Procedure Call). O editor atua como cliente, e o compilador ou ferramenta de linguagem atua como servidor.
Cliente (Editor): Gerencia interação com o usuário, renderiza UI, e envia notificações ao servidor sobre ações do desenvolvedor como abrir arquivos, editar código, ou salvar mudanças. Também requisita capacidades específicas como autocompletar ou navegação para definição.
Servidor (Compilador): Mantém modelo interno do código-fonte, realiza todas as análises necessárias, e responde a requisições do cliente com informações relevantes. O servidor é responsável por toda a lógica de linguagem - o cliente não precisa entender a semântica da linguagem.
Comunicação JSON-RPC: Mensagens entre cliente e servidor são codificadas em JSON seguindo convenções RPC. Existem três tipos de mensagens: requisições (cliente solicita resposta do servidor), respostas (servidor responde a requisição), e notificações (mensagens unidirecionais sem resposta esperada).
🔄 Ciclo de Vida de Conexão LSP
Inicialização: Cliente envia requisição initialize com suas capacidades. Servidor responde com suas próprias capacidades, negociando quais features serão suportadas nesta sessão.
Operação normal: Cliente envia notificações quando documentos são abertos, modificados, ou fechados. Cliente faz requisições quando usuário solicita funcionalidades como autocompletar. Servidor envia diagnósticos proativamente quando detecta erros.
Sincronização de documentos: Cliente mantém servidor atualizado sobre estado dos documentos através de notificações. Para mudanças incrementais, cliente pode enviar apenas os deltas em vez de documento completo.
Shutdown: Cliente envia requisição shutdown seguida de notificação exit quando conexão deve terminar. Isto permite ao servidor limpar recursos graciosamente.
A comunicação pode ocorrer através de diferentes transportes. O mais comum é stdio (standard input/output) onde servidor lê requisições de stdin e escreve respostas em stdout. Também é possível usar sockets TCP ou named pipes. O protocolo é agnóstico ao transporte - apenas mensagens JSON importam.
Principais Capacidades do LSP
O LSP define dezenas de capacidades que servidores podem implementar. Vamos explorar as mais importantes e úteis.
Diagnósticos: Erros, avisos, e informações são reportados ao cliente como diagnósticos. Cada diagnóstico inclui posição no código, severidade, mensagem, e opcionalmente um código de erro e source. Diagnósticos podem ser enviados proativamente pelo servidor quando ele detecta problemas, ou em resposta a mudanças.
Completion (Autocompletar): Quando desenvolvedor está digitando, cliente pode requisitar sugestões de completar. Servidor retorna lista de itens possíveis baseado no contexto. Cada item de completion inclui label, tipo (função, variável, classe, etc.), documentação, e texto a inserir. Completion avançado pode incluir snippets com placeholders.
Hover: Quando usuário passa mouse sobre código, cliente requisita informações de hover. Servidor retorna documentação relevante, tipos de expressões, ou outras informações contextuais. Isto é essencial para desenvolvedores explorarem código sem precisar buscar documentação externa.
Signature Help: Durante digitação de chamadas de função, cliente pode requisitar informação sobre parâmetros. Servidor retorna assinatura da função com documentação de cada parâmetro. Isto guia desenvolvedores sobre argumentos esperados.
Go to Definition: Usuário pode saltar para definição de símbolo. Cliente envia posição do cursor, servidor responde com localização da definição. Isto permite navegação rápida através de código.
Find References: Inverso de go to definition - encontrar todos os lugares onde símbolo é usado. Essencial para refatoração e compreensão de impacto de mudanças.
Rename: Renomeação consistente de símbolos em todo o projeto. Cliente envia posição do símbolo e novo nome, servidor calcula todas as mudanças necessárias e as retorna como WorkspaceEdit.
Code Actions (Quick Fixes): Quando há diagnósticos, servidor pode oferecer correções automáticas. Por exemplo, para erro “variável não declarada”, pode oferecer ação para inserir declaração. Code actions são extremamente poderosas para melhorar produtividade.
import 'dart:convert';
import 'dart:io';
// Representações de tipos LSP fundamentais
class Position {
final int line;
final int character;
Position(this.line, this.character);
Map<String, dynamic> toJson() => {
'line': line,
'character': character,
};
factory Position.fromJson(Map<String, dynamic> json) => Position(
json['line'] as int,
json['character'] as int,
);
}
class Range {
final Position start;
final Position end;
Range(this.start, this.end);
Map<String, dynamic> toJson() => {
'start': start.toJson(),
'end': end.toJson(),
};
}
class Location {
final String uri;
final Range range;
Location(this.uri, this.range);
Map<String, dynamic> toJson() => {
'uri': uri,
'range': range.toJson(),
};
}
class Diagnostic {
final Range range;
final String message;
final int severity; // 1=Error, 2=Warning, 3=Information, 4=Hint
final String? code;
final String? source;
Diagnostic({
required this.range,
required this.message,
required this.severity,
this.code,
this.source,
});
Map<String, dynamic> toJson() => {
'range': range.toJson(),
'message': message,
'severity': severity,
if (code != null) 'code': code,
if (source != null) 'source': source,
};
}
// Servidor LSP simplificado
class ServidorLSP {
final Map<String, String> documentos = {}; // URI -> conteúdo
final Map<String, List<Diagnostic>> diagnosticos = {};
void iniciar() {
// Ler mensagens JSON-RPC de stdin
stdin.transform(utf8.decoder).transform(const LineSplitter()).listen(
(linha) {
if (linha.startsWith('Content-Length:')) {
// Protocolo LSP usa headers HTTP-style
final tamanho = int.parse(linha.split(':')[1].trim());
// Ler tamanho bytes da próxima mensagem
// (simplificado - implementação real é mais complexa)
}
},
);
}
void processarMensagem(Map<String, dynamic> mensagem) {
final metodo = mensagem['method'] as String?;
if (metodo == 'initialize') {
tratarInitialize(mensagem);
} else if (metodo == 'textDocument/didOpen') {
tratarDidOpen(mensagem);
} else if (metodo == 'textDocument/didChange') {
tratarDidChange(mensagem);
} else if (metodo == 'textDocument/completion') {
tratarCompletion(mensagem);
} else if (metodo == 'textDocument/hover') {
tratarHover(mensagem);
} else if (metodo == 'textDocument/definition') {
tratarDefinition(mensagem);
}
}
void tratarInitialize(Map<String, dynamic> msg) {
final id = msg['id'];
// Responder com capacidades do servidor
final resposta = {
'jsonrpc': '2.0',
'id': id,
'result': {
'capabilities': {
'textDocumentSync': 1, // Full sync
'completionProvider': {'triggerCharacters': ['.']},
'hoverProvider': true,
'definitionProvider': true,
'referencesProvider': true,
'renameProvider': true,
'codeActionProvider': true,
}
}
};
enviarMensagem(resposta);
}
void tratarDidOpen(Map<String, dynamic> msg) {
final params = msg['params'] as Map<String, dynamic>;
final doc = params['textDocument'] as Map<String, dynamic>;
final uri = doc['uri'] as String;
final texto = doc['text'] as String;
// Armazenar documento
documentos[uri] = texto;
// Analisar e gerar diagnósticos
final diags = analisarDocumento(texto);
diagnosticos[uri] = diags;
// Enviar diagnósticos ao cliente
enviarDiagnosticos(uri, diags);
}
void tratarDidChange(Map<String, dynamic> msg) {
final params = msg['params'] as Map<String, dynamic>;
final uri = params['textDocument']['uri'] as String;
final mudancas = params['contentChanges'] as List;
// Aplicar mudanças (simplificado - assume full sync)
final novoTexto = mudancas[0]['text'] as String;
documentos[uri] = novoTexto;
// Reanalisar e atualizar diagnósticos
final diags = analisarDocumento(novoTexto);
diagnosticos[uri] = diags;
enviarDiagnosticos(uri, diags);
}
void tratarCompletion(Map<String, dynamic> msg) {
final id = msg['id'];
final params = msg['params'] as Map<String, dynamic>;
final uri = params['textDocument']['uri'] as String;
final posicao = Position.fromJson(params['position']);
// Gerar sugestões de completion baseado no contexto
final itens = gerarCompletion(uri, posicao);
final resposta = {
'jsonrpc': '2.0',
'id': id,
'result': itens.map((item) => item.toJson()).toList(),
};
enviarMensagem(resposta);
}
void tratarHover(Map<String, dynamic> msg) {
final id = msg['id'];
final params = msg['params'] as Map<String, dynamic>;
final uri = params['textDocument']['uri'] as String;
final posicao = Position.fromJson(params['position']);
// Obter informações de hover
final info = obterHoverInfo(uri, posicao);
final resposta = {
'jsonrpc': '2.0',
'id': id,
'result': info != null ? {
'contents': {
'kind': 'markdown',
'value': info,
}
} : null,
};
enviarMensagem(resposta);
}
void tratarDefinition(Map<String, dynamic> msg) {
final id = msg['id'];
final params = msg['params'] as Map<String, dynamic>;
final uri = params['textDocument']['uri'] as String;
final posicao = Position.fromJson(params['position']);
// Encontrar definição do símbolo
final localizacao = encontrarDefinicao(uri, posicao);
final resposta = {
'jsonrpc': '2.0',
'id': id,
'result': localizacao?.toJson(),
};
enviarMensagem(resposta);
}
void enviarDiagnosticos(String uri, List<Diagnostic> diags) {
final notificacao = {
'jsonrpc': '2.0',
'method': 'textDocument/publishDiagnostics',
'params': {
'uri': uri,
'diagnostics': diags.map((d) => d.toJson()).toList(),
}
};
enviarMensagem(notificacao);
}
void enviarMensagem(Map<String, dynamic> msg) {
final conteudo = jsonEncode(msg);
final header = 'Content-Length: ${conteudo.length}\r\n\r\n';
stdout.write(header);
stdout.write(conteudo);
stdout.flush();
}
// Métodos de análise (simplificados - implementação real seria muito mais complexa)
List<Diagnostic> analisarDocumento(String texto) {
final diags = <Diagnostic>[];
// Exemplo: detectar palavras TODO como hints
final linhas = texto.split('\n');
for (var i = 0; i < linhas.length; i++) {
if (linhas[i].contains('TODO')) {
diags.add(Diagnostic(
range: Range(Position(i, 0), Position(i, linhas[i].length)),
message: 'TODO encontrado',
severity: 4, // Hint
source: 'meu-compilador',
));
}
}
return diags;
}
List<CompletionItem> gerarCompletion(String uri, Position pos) {
// Implementação real consultaria tabela de símbolos
return [
CompletionItem('variavel', 6, 'Variável local'),
CompletionItem('funcao', 3, 'Função disponível'),
];
}
String? obterHoverInfo(String uri, Position pos) {
return '**Tipo**: `int`\n\nVariável local declarada na linha 5';
}
Location? encontrarDefinicao(String uri, Position pos) {
// Implementação real buscaria na tabela de símbolos
return Location(
uri,
Range(Position(5, 4), Position(5, 15)),
);
}
}
class CompletionItem {
final String label;
final int kind; // 6=Variable, 3=Function, etc
final String documentation;
CompletionItem(this.label, this.kind, this.documentation);
Map<String, dynamic> toJson() => {
'label': label,
'kind': kind,
'documentation': documentation,
};
}#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <sstream>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
// Estruturas LSP fundamentais
struct Position {
int line;
int character;
json toJson() const {
return {{"line", line}, {"character", character}};
}
};
struct Range {
Position start;
Position end;
json toJson() const {
return {{"start", start.toJson()}, {"end", end.toJson()}};
}
};
struct Diagnostic {
Range range;
std::string message;
int severity; // 1=Error, 2=Warning, 3=Info, 4=Hint
json toJson() const {
return {
{"range", range.toJson()},
{"message", message},
{"severity", severity}
};
}
};
// Servidor LSP simplificado
class ServidorLSP {
private:
std::map<std::string, std::string> documentos;
std::map<std::string, std::vector<Diagnostic>> diagnosticos;
void enviarMensagem(const json& msg) {
std::string conteudo = msg.dump();
std::cout << "Content-Length: " << conteudo.length()
<< "\r\n\r\n" << conteudo << std::flush;
}
std::vector<Diagnostic> analisarDocumento(const std::string& texto) {
std::vector<Diagnostic> diags;
// Exemplo: encontrar TODOs
std::istringstream stream(texto);
std::string linha;
int numeroLinha = 0;
while (std::getline(stream, linha)) {
if (linha.find("TODO") != std::string::npos) {
Diagnostic diag;
diag.range = {{numeroLinha, 0}, {numeroLinha, (int)linha.length()}};
diag.message = "TODO encontrado";
diag.severity = 4; // Hint
diags.push_back(diag);
}
numeroLinha++;
}
return diags;
}
public:
void processarMensagem(const json& msg) {
std::string metodo = msg["method"];
if (metodo == "initialize") {
tratarInitialize(msg);
} else if (metodo == "textDocument/didOpen") {
tratarDidOpen(msg);
} else if (metodo == "textDocument/didChange") {
tratarDidChange(msg);
} else if (metodo == "textDocument/completion") {
tratarCompletion(msg);
}
}
void tratarInitialize(const json& msg) {
json resposta = {
{"jsonrpc", "2.0"},
{"id", msg["id"]},
{"result", {
{"capabilities", {
{"textDocumentSync", 1},
{"completionProvider", {
{"triggerCharacters", {"."}}
}},
{"hoverProvider", true},
{"definitionProvider", true}
}}
}}
};
enviarMensagem(resposta);
}
void tratarDidOpen(const json& msg) {
auto params = msg["params"];
std::string uri = params["textDocument"]["uri"];
std::string texto = params["textDocument"]["text"];
documentos[uri] = texto;
auto diags = analisarDocumento(texto);
diagnosticos[uri] = diags;
enviarDiagnosticos(uri, diags);
}
void tratarDidChange(const json& msg) {
auto params = msg["params"];
std::string uri = params["textDocument"]["uri"];
std::string novoTexto = params["contentChanges"][0]["text"];
documentos[uri] = novoTexto;
auto diags = analisarDocumento(novoTexto);
diagnosticos[uri] = diags;
enviarDiagnosticos(uri, diags);
}
void tratarCompletion(const json& msg) {
json resposta = {
{"jsonrpc", "2.0"},
{"id", msg["id"]},
{"result", json::array({
{{"label", "variavel"}, {"kind", 6}},
{{"label", "funcao"}, {"kind", 3}}
})}
};
enviarMensagem(resposta);
}
void enviarDiagnosticos(const std::string& uri,
const std::vector<Diagnostic>& diags) {
json diagsJson = json::array();
for (const auto& diag : diags) {
diagsJson.push_back(diag.toJson());
}
json notificacao = {
{"jsonrpc", "2.0"},
{"method", "textDocument/publishDiagnostics"},
{"params", {
{"uri", uri},
{"diagnostics", diagsJson}
}}
};
enviarMensagem(notificacao);
}
};🎨 Integração com Visual Studio Code
Arquitetura de Extensões VS Code
Visual Studio Code utiliza modelo de extensões para adicionar funcionalidades. Extensões rodam em processos separados do editor principal, comunicando através de APIs bem definidas. Para integrar um servidor LSP, você cria uma extensão que atua como bridge entre o VS Code e seu servidor.
A estrutura básica de uma extensão VS Code consiste em:
package.json: Arquivo manifest que descreve a extensão, suas dependências, capacidades, e pontos de ativação. Este arquivo especifica quando a extensão deve ser carregada (por exemplo, ao abrir arquivos de certa extensão).
extension.ts/js: Código principal da extensão que implementa a lógica de ativação e configuração. Para extensões LSP, este arquivo tipicamente configura e inicia o servidor LSP.
language-configuration.json: Define configurações específicas da linguagem como caracteres de comentário, pares de brackets, e regras de auto-indentação.
**syntaxes/*.tmLanguage.json**: Gramática TextMate para colorização sintática básica. Isto fornece realce de sintaxe imediato enquanto o servidor LSP carrega.
📦 Estrutura de Pasta de Extensão LSP
minha-linguagem-extension/
├── package.json # Manifest da extensão
├── language-configuration.json # Configuração da linguagem
├── syntaxes/
│ └── minha-linguagem.tmLanguage.json # Gramática TextMate
├── src/
│ └── extension.ts # Código principal da extensão
├── server/
│ └── main.ts # Servidor LSP
├── README.md # Documentação
└── .vscodeignore # Arquivos a ignorar no empacotamento
A pasta server/ contém o servidor LSP propriamente dito, que pode ser implementado em qualquer linguagem. A extensão simplesmente inicia este servidor como processo filho e estabelece comunicação através de stdio.
Implementação de Extensão LSP Mínima
Vamos criar uma extensão mínima mas funcional que conecta um servidor LSP ao VS Code.
{
"name": "didagica-language",
"displayName": "Didágica Language Support",
"description": "Suporte para linguagem Didágica com LSP",
"version": "0.1.0",
"publisher": "seu-nome",
"engines": {
"vscode": "^1.75.0"
},
"categories": ["Programming Languages"],
"activationEvents": [
"onLanguage:didagica"
],
"main": "./out/extension.js",
"contributes": {
"languages": [{
"id": "didagica",
"aliases": ["Didágica", "didagica"],
"extensions": [".did"],
"configuration": "./language-configuration.json"
}],
"grammars": [{
"language": "didagica",
"scopeName": "source.didagica",
"path": "./syntaxes/didagica.tmLanguage.json"
}]
},
"dependencies": {
"vscode-languageclient": "^8.1.0"
},
"devDependencies": {
"@types/vscode": "^1.75.0",
"@types/node": "^18.0.0",
"typescript": "^4.9.0"
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./"
}
}import * as path from 'path';
import { workspace, ExtensionContext } from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: ExtensionContext) {
// Caminho para o executável do servidor
const serverModule = context.asAbsolutePath(
path.join('server', 'out', 'main.js')
);
// Opções de debug (para desenvolvimento)
const debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };
// Configuração do servidor LSP
const serverOptions: ServerOptions = {
run: {
module: serverModule,
transport: TransportKind.ipc
},
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: debugOptions
}
};
// Opções do cliente
const clientOptions: LanguageClientOptions = {
// Registrar servidor para documentos Didágica
documentSelector: [{ scheme: 'file', language: 'didagica' }],
synchronize: {
// Notificar servidor sobre mudanças em arquivos .did
fileEvents: workspace.createFileSystemWatcher('**/*.did')
}
};
// Criar e iniciar cliente LSP
client = new LanguageClient(
'didagicaLanguageServer',
'Didágica Language Server',
serverOptions,
clientOptions
);
// Iniciar cliente (também inicia servidor)
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}{
"comments": {
"lineComment": "//",
"blockComment": ["/*", "*/"]
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string"] },
{ "open": "'", "close": "'", "notIn": ["string", "comment"] }
],
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"]
],
"folding": {
"markers": {
"start": "^\\s*//\\s*#?region\\b",
"end": "^\\s*//\\s*#?endregion\\b"
}
},
"wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\#\\%\\^\\&\\*\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s]+)",
"indentationRules": {
"increaseIndentPattern": "^((?!\\/\\/).)*(\\{[^}\"'`]*|\\([^)\"'`]*|\\[[^\\]\"'`]*)$",
"decreaseIndentPattern": "^((?!.*?\\/\\*).*\\*/)?\\s*[\\)\\}\\]].*$"
}
}{
"scopeName": "source.didagica",
"patterns": [
{ "include": "#keywords" },
{ "include": "#strings" },
{ "include": "#comments" },
{ "include": "#numbers" }
],
"repository": {
"keywords": {
"patterns": [{
"name": "keyword.control.didagica",
"match": "\\b(if|else|while|for|return|let|const)\\b"
}]
},
"strings": {
"name": "string.quoted.double.didagica",
"begin": "\"",
"end": "\"",
"patterns": [{
"name": "constant.character.escape.didagica",
"match": "\\\\."
}]
},
"comments": {
"patterns": [
{
"name": "comment.line.double-slash.didagica",
"match": "//.*$"
},
{
"name": "comment.block.didagica",
"begin": "/\\*",
"end": "\\*/"
}
]
},
"numbers": {
"name": "constant.numeric.didagica",
"match": "\\b\\d+(\\.\\d+)?\\b"
}
}
}Testando e Depurando Extensões
Desenvolvimento de extensões VS Code é facilitado por ferramentas de debugging integradas. Você pode executar e depurar extensões diretamente dentro do VS Code.
Execução de debug: Pressione F5 no projeto da extensão para abrir nova janela do VS Code (Extension Development Host) com sua extensão carregada. Você pode definir breakpoints no código da extensão e do servidor LSP.
Console de debug: Output e errors do servidor LSP aparecem no painel Output do VS Code, no canal “Didágica Language Server”. Isto é essencial para diagnosticar problemas.
Inspeção de mensagens LSP: Para ver detalhes das mensagens trocadas entre cliente e servidor, ative logging em clientOptions:
Reload da extensão: Durante desenvolvimento, você pode recarregar a extensão sem reiniciar o VS Code usando o comando “Developer: Reload Window” (Ctrl+R na janela Extension Development Host).
🚀 Recursos Avançados e Otimizações
Caching e Invalidação Inteligente
Para manter responsividade em projetos grandes, servidores LSP implementam estratégias sofisticadas de caching. A ideia é computar informações caras uma vez e reutilizá-las até que mudanças invalidem o cache.
Cache de símbolos por arquivo: Tabela de símbolos para cada arquivo pode ser cached. Quando arquivo muda, apenas seu cache é invalidado. Arquivos dependentes podem ter caches parcialmente invalidados se importam símbolos do arquivo modificado.
Cache de diagnósticos: Após análise, diagnósticos são cached. Mudanças incrementais podem permitir reutilizar diagnósticos de regiões não afetadas.
Cache de tipos: Para linguagens com inferência de tipos, os tipos inferidos podem ser cached agressivamente. Mudanças locais frequentemente não afetam tipos de código distante.
⚠️ Desafios de Invalidação
O problema difícil não é caching, mas invalidação correta. Caches incorretos levam a diagnósticos desatualizados, autocompletar errado, ou navegação quebrada - experiências extremamente frustrantes para desenvolvedores.
Estratégias conservadoras invalidam mais cache que necessário, sacrificando performance por correção. Estratégias agressivas tentam invalidar minimamente, mas requerem análise de dependências sofisticada para garantir correção.
A escolha da estratégia depende da linguagem. Linguagens com inferência global de tipos requerem invalidação ampla. Linguagens com escopo local permitem invalidação precisa.
Análise Paralela e Concorrência
Servidores LSP modernos processam múltiplos arquivos em paralelo para aproveitar processadores multi-core. Isto é especialmente importante para projetos grandes onde análise serial seria proibitivamente lenta.
Análise de arquivos independentes: Arquivos sem dependências podem ser analisados completamente em paralelo. Worker threads ou processos separados processam subconjuntos do projeto simultaneamente.
Pipeline de análise: Mesmo para arquivo único, diferentes fases podem ser pipelined. Enquanto análise semântica processa função anterior, análise sintática pode processar função seguinte.
Sincronização cuidadosa: Estruturas compartilhadas como tabela de símbolos global requerem sincronização. Locks podem criar gargalos, então designs lock-free ou estruturas persistentes são preferidas.
Progressive Enhancement
Servidores LSP sofisticados fornecem feedback progressivo - começam com informações básicas rapidamente e refinam incrementalmente.
Diagnósticos rápidos: Erros sintáticos são reportados imediatamente durante parsing. Erros semânticos mais complexos aparecem depois que análise de tipos completa.
Completion incremental: Sugestões básicas baseadas em sintaxe aparecem instantaneamente. Sugestões semânticamente conscientes aparecem alguns milissegundos depois.
Análise de background: Enquanto desenvolvedor trabalha em um arquivo, servidor pode analisar outros arquivos do projeto em background, preparando informações para quando desenvolvedor navegar para eles.
🎯 Integrando Seu Compilador com LSP
Mapeando Capacidades do Compilador para LSP
Seu compilador já computa muitas informações valiosas que podem ser expostas através de LSP. Vamos mapear sistematicamente cada capacidade do compilador para features LSP correspondentes.
Análise léxica → Coloração sintática: Tokens identificados pelo lexer podem ser usados para coloração sintática semântica (diferente da coloração básica por TextMate). Por exemplo, distinguir variáveis locais de parâmetros de funções.
Análise sintática → Outline/Estrutura: A AST fornece hierarquia estrutural do código que pode ser exposta como document symbols, permitindo outline views e navegação estrutural.
Tabela de símbolos → Navigation: Definições e usos de símbolos registrados durante análise semântica suportam go-to-definition, find-references, e rename.
Análise de tipos → Hover e Signature Help: Tipos inferidos ou checados podem ser mostrados em tooltips. Assinaturas de funções suportam signature help durante chamadas.
Checagem de erros → Diagnósticos: Todos os erros e warnings do compilador mapeiam naturalmente para diagnósticos LSP.
Sugestões do compilador → Code Actions: Quando compilador pode sugerir correções (como adicionar imports ausentes), estas tornam-se code actions.
🔧 Adaptando Estruturas Internas
A principal tarefa ao integrar com LSP é adaptar estruturas internas do compilador para formatos esperados pelo protocolo.
Posições: LSP usa posições baseadas em linha e coluna (zero-indexed). Se seu compilador rastreia offsets de bytes, precisará converter. Mantenha mapeamento de offsets para posições de linha/coluna.
Ranges: LSP usa ranges com início e fim exclusivo. Certifique-se que seus spans de AST seguem esta convenção ou converta apropriadamente.
URIs vs Paths: LSP usa URIs (file:///path/to/file.did) enquanto compiladores tipicamente usam caminhos filesystem. Converta consistentemente entre formatos.
Encoding: LSP especifica encoding (tipicamente UTF-8) para offsets de caractere. Strings multi-byte requerem cuidado ao calcular posições de caractere.
Arquitetura Recomendada para Servidor LSP
Para implementar servidor LSP robusto para seu compilador, sugiro a seguinte arquitetura em camadas.
Camada de protocolo: Responsável por comunicação JSON-RPC, parsing de mensagens, e dispatch para handlers apropriados. Esta camada não entende semântica da linguagem.
Camada de gerenciamento de documentos: Rastreia estado de todos os documentos abertos, aplica mudanças incrementais, e notifica camadas superiores sobre alterações.
Camada de análise: Invoca compilador para analisar documentos, gerencia parsing incremental, e mantém ASTs atualizadas. Esta camada é a interface entre LSP e seu compilador existente.
Camada de consultas: Responde a requisições LSP consultando informações computadas pela análise. Implementa lógica para completion, hover, definition, etc.
Camada de cache: Gerencia caching de resultados intermediários e invalidação quando documentos mudam.
// Arquitetura em camadas para servidor LSP
// Camada 1: Protocolo JSON-RPC
class ProtocolHandler {
final StreamController<Map<String, dynamic>> _mensagensEntrada;
final StreamSink<Map<String, dynamic>> _mensagensSaida;
void processarMensagem(String conteudo) {
final msg = jsonDecode(conteudo);
_mensagensEntrada.add(msg as Map<String, dynamic>);
}
void enviarMensagem(Map<String, dynamic> msg) {
_mensagensSaida.add(msg);
}
}
// Camada 2: Gerenciamento de Documentos
class GerenciadorDocumentos {
final Map<String, DocumentState> _documentos = {};
void abrirDocumento(String uri, String texto) {
_documentos[uri] = DocumentState(uri, texto, versao: 0);
// Notificar camadas superiores
_notificarMudanca(uri);
}
void modificarDocumento(
String uri,
List<TextDocumentContentChangeEvent> mudancas,
) {
final doc = _documentos[uri];
if (doc == null) return;
// Aplicar mudanças incrementalmente
for (final mudanca in mudancas) {
doc.aplicarMudanca(mudanca);
}
doc.versao++;
_notificarMudanca(uri);
}
String? obterTexto(String uri) => _documentos[uri]?.texto;
void _notificarMudanca(String uri) {
// Notificar analisador para reparsar
}
}
class DocumentState {
final String uri;
String texto;
int versao;
DocumentState(this.uri, this.texto, {required this.versao});
void aplicarMudanca(TextDocumentContentChangeEvent mudanca) {
// Se mudança tem range, aplicar incrementalmente
if (mudanca.range != null) {
final offset = _posicaoParaOffset(mudanca.range!.start);
final offsetFim = _posicaoParaOffset(mudanca.range!.end);
texto = texto.substring(0, offset) +
mudanca.text +
texto.substring(offsetFim);
} else {
// Substituição completa
texto = mudanca.text;
}
}
int _posicaoParaOffset(Position pos) {
// Converter posição linha/coluna para offset de byte
final linhas = texto.split('\n');
int offset = 0;
for (int i = 0; i < pos.line; i++) {
offset += linhas[i].length + 1; // +1 para newline
}
offset += pos.character;
return offset;
}
}
// Camada 3: Análise
class CamadaAnalise {
final SeuCompilador compilador;
final GerenciadorCache cache;
Future<ResultadoAnalise> analisar(String uri, String texto) async {
// Verificar cache primeiro
final cached = cache.obterResultado(uri, texto);
if (cached != null) return cached;
// Análise completa
final lexer = compilador.criarLexer(texto);
final tokens = lexer.tokenizar();
final parser = compilador.criarParser(tokens);
final ast = parser.parsear();
final analisadorSemantico = compilador.criarAnalisadorSemantico();
final resultado = analisadorSemantico.analisar(ast);
// Cachear resultado
cache.armazenarResultado(uri, texto, resultado);
return resultado;
}
Future<ResultadoAnalise> analisarIncremental(
String uri,
String textoNovo,
ResultadoAnalise anterior,
Range rangeModificado,
) async {
// Parsing incremental
final parser = compilador.criarParserIncremental(anterior.ast);
final novaAst = parser.atualizarIncremental(
rangeModificado,
textoNovo,
);
// Reanálise semântica apenas da região afetada
final analisadorSemantico = compilador.criarAnalisadorSemantico();
final resultado = analisadorSemantico.analisarIncremental(
novaAst,
anterior,
rangeModificado,
);
return resultado;
}
}
class ResultadoAnalise {
final ArvoreIncrementalNode ast;
final TabelaSimbolos tabelaSimbolos;
final Map<ArvoreIncrementalNode, TipoNode> tipos;
final List<Diagnostic> diagnosticos;
ResultadoAnalise({
required this.ast,
required this.tabelaSimbolos,
required this.tipos,
required this.diagnosticos,
});
}
// Camada 4: Consultas
class CamadaConsultas {
final CamadaAnalise analise;
List<CompletionItem> obterCompletion(
String uri,
Position posicao,
) {
final resultado = analise.obterResultadoAtual(uri);
if (resultado == null) return [];
// Encontrar contexto na posição
final contexto = _encontrarContexto(resultado.ast, posicao);
// Gerar sugestões baseado no contexto
final sugestoes = <CompletionItem>[];
// Adicionar variáveis locais
final escopoLocal = resultado.tabelaSimbolos.obterEscopoEm(posicao);
for (final simbolo in escopoLocal.simbolos.values) {
sugestoes.add(CompletionItem(
simbolo.nome,
_tipoParaCompletionKind(simbolo.tipo),
simbolo.documentacao ?? '',
));
}
// Adicionar palavras-chave se apropriado
if (_contextoPermitePalavrasChave(contexto)) {
sugestoes.addAll(_palavrasChave());
}
return sugestoes;
}
String? obterHover(String uri, Position posicao) {
final resultado = analise.obterResultadoAtual(uri);
if (resultado == null) return null;
// Encontrar nó na posição
final no = _encontrarNoEm(resultado.ast, posicao);
if (no == null) return null;
// Se é referência a símbolo, mostrar tipo e documentação
if (no.tipo == 'Identificador') {
final simbolo = resultado.tabelaSimbolos.resolver(no.nome);
if (simbolo != null) {
return '**${simbolo.nome}**: `${simbolo.tipo}`\n\n'
'${simbolo.documentacao ?? ''}';
}
}
// Se é expressão, mostrar tipo inferido
final tipo = resultado.tipos[no];
if (tipo != null) {
return 'Tipo: `$tipo`';
}
return null;
}
Location? obterDefinicao(String uri, Position posicao) {
final resultado = analise.obterResultadoAtual(uri);
if (resultado == null) return null;
final no = _encontrarNoEm(resultado.ast, posicao);
if (no == null || no.tipo != 'Identificador') return null;
final simbolo = resultado.tabelaSimbolos.resolver(no.nome);
if (simbolo?.localizacaoDefinicao != null) {
return simbolo!.localizacaoDefinicao;
}
return null;
}
// Métodos auxiliares simplificados
ArvoreIncrementalNode? _encontrarContexto(
ArvoreIncrementalNode raiz,
Position pos,
) {
// Implementação real seria mais sofisticada
return raiz;
}
ArvoreIncrementalNode? _encontrarNoEm(
ArvoreIncrementalNode raiz,
Position pos,
) {
// Busca recursiva pelo nó que contém a posição
return null;
}
int _tipoParaCompletionKind(String tipo) {
// Mapear tipos da linguagem para CompletionItemKind do LSP
return 6; // Variable
}
bool _contextoPermitePalavrasChave(ArvoreIncrementalNode contexto) {
return true;
}
List<CompletionItem> _palavrasChave() {
return [
CompletionItem('if', 14, 'Condicional if'),
CompletionItem('while', 14, 'Loop while'),
CompletionItem('let', 14, 'Declaração de variável'),
];
}
}
// Camada 5: Cache
class GerenciadorCache {
final Map<String, CacheEntry> _cache = {};
ResultadoAnalise? obterResultado(String uri, String textoAtual) {
final entry = _cache[uri];
if (entry != null && entry.texto == textoAtual) {
return entry.resultado;
}
return null;
}
void armazenarResultado(
String uri,
String texto,
ResultadoAnalise resultado,
) {
_cache[uri] = CacheEntry(texto, resultado);
}
void invalidar(String uri) {
_cache.remove(uri);
}
}
class CacheEntry {
final String texto;
final ResultadoAnalise resultado;
CacheEntry(this.texto, this.resultado);
}Esta arquitetura mantém separação clara de responsabilidades, facilita testes unitários de cada camada, e permite escalar para projetos grandes através de otimizações targeted em camadas específicas.
💎 Exemplos Práticos de Features Avançadas
Quick Fixes Inteligentes
Quick fixes são code actions que corrigem automaticamente problemas comuns. Elas transformam diagnósticos passivos em oportunidades interativas de correção.
✨ Exemplos de Quick Fixes Valiosos
Variável não declarada: Quando variável é usada sem declaração, oferecer ação para inserir declaração apropriada baseado no uso inferido.
Import ausente: Se símbolo referenciado não está no escopo mas existe em módulo disponível, oferecer ação para adicionar import.
Tipo incompatível: Se expressão tem tipo errado mas conversão explícita resolveria, oferecer ação para inserir conversão.
Código inalcançável: Se análise de fluxo detecta código nunca executado, oferecer ação para remover ou comentar.
Naming conventions: Se identificador viola convenções de nomenclatura, oferecer ação para renomear automaticamente.
Implementar quick fixes requer raciocinar sobre como código deveria ser modificado para resolver problema. Isto vai além de simples detecção de erros, envolvendo geração de código corretivo.
Refatorações Automatizadas
Refatorações são transformações que preservam semântica mas melhoram estrutura. LSP suporta refatorações complexas através de workspace edits que podem modificar múltiplos arquivos simultaneamente.
Extract method: Selecionar trecho de código e extrair para método separado. Servidor deve analisar dependências, gerar assinatura apropriada, e atualizar todos os lugares onde código extraído é relevante.
Rename símbolo: Renomear consistentemente em todo o projeto. Requer encontrar todas as referências, distinguir usos de definições, e lidar com shadowing de nomes.
Inline variable: Substituir todas as referências a variável por sua definição. Requer garantir que efeitos colaterais não são duplicados inadvertidamente.
Extract interface: Criar interface a partir de classe existente. Requer análise de quais membros são públicos e devem fazer parte da interface.
Semantic Tokens
Além de coloração sintática básica via TextMate, LSP suporta semantic tokens que usam análise semântica para coloração mais precisa e rica.
Por exemplo, identificadores podem ser colorados diferentemente baseado em se são variáveis locais, parâmetros, campos, ou constantes. Funções podem ser distinguidas de métodos. Tipos genéricos podem ter cor especial.
Semantic tokens permitem editores fornecerem coloração que reflete significado do código, não apenas estrutura sintática superficial.
🔮 Olhando Para o Futuro
Tendências em Ferramentas de Linguagem
O campo de ferramentas de linguagem continua evoluindo rapidamente. Algumas tendências empolgantes:
AI-assisted coding: Ferramentas como GitHub Copilot integram modelos de linguagem para sugestões de código. Futuros servidores LSP podem incorporar ML para sugestões mais inteligentes.
Cloud-based analysis: Para projetos massivos, análise pode ocorrer em cloud com servidores LSP remotos. Isto permite aproveitar recursos computacionais imensos.
Multi-language analysis: Projetos modernos misturam múltiplas linguagens. Ferramentas futuras compreenderão interações entre linguagens, rastreando dependências cross-language.
Verification-as-you-type: Ferramentas de verificação formal podem ser integradas via LSP, fornecendo provas de correção incrementalmente durante desenvolvimento.
Oportunidades de Contribuição
O ecossistema LSP é aberto e colaborativo. Oportunidades para contribuir incluem:
Implementar servidor LSP para linguagem existente: Muitas linguagens carecem de servidores LSP de qualidade. Implementar um é contribuição valiosa.
Melhorar servidores existentes: A maioria dos servidores LSP tem muitas oportunidades de melhorias - performance, features faltantes, bugs.
Criar extensões especializadas: Extensões que integram ferramentas adicionais (linters, formatters, test runners) através de LSP agregam valor imenso.
Documentação e tutoriais: Recursos educacionais sobre implementação de servidores LSP ajudam democratizar ainda mais o ecossistema.
🎓 Reflexões Finais sobre sua Jornada
Celebrando o Que Você Construiu
Parabéns por chegar ao final desta jornada extraordinária! Ao longo de quinze semanas intensas, você transformou conceitos matemáticos abstratos em um compilador completo e moderno. Mais importante, você integrou este compilador com ferramentas de desenvolvimento contemporâneas, criando uma experiência profissional que desenvolvedores realmente usariam.
Pense em tudo que você dominou: fundamentos matemáticos de teoria da computação, linguagens formais e automatos, técnicas de parsing, análise semântica, geração de código, e agora parsing incremental e Language Server Protocol. Pouquíssimos desenvolvedores compreendem profundamente toda esta pilha. Você faz parte de um grupo seleto.
🌟 Habilidades Transferíveis que Você Desenvolveu
As competências que você adquiriu transcendem compiladores específicos:
Raciocínio sobre sistemas complexos: Você aprendeu a decompor problemas imensos em componentes gerenciáveis, projetar interfaces limpas entre módulos, e orquestrar colaboração entre partes.
Pensamento formal e rigoroso: A prática constante com matemática formal desenvolveu sua capacidade de raciocinar precisamente sobre comportamento de sistemas.
Engineering de performance: Otimizações incrementais, caching, e técnicas de paralelização que você explorou são aplicáveis a qualquer sistema de software exigente.
Integração e APIs: Trabalho com LSP ensinou princípios de design de APIs, protocolos de comunicação, e integração de sistemas independentes.
Persistência e resolução de problemas: Debugging de compiladores é notoriamente difícil. As estratégias que você desenvolveu para sistematicamente isolar e corrigir bugs são invaluáveis.
Aplicando seu Conhecimento Além de Compiladores
As técnicas que você dominou têm aplicações surpreendentemente amplas fora de compiladores tradicionais:
Ferramentas de análise de código: Linters, formatters, e checkers de estilo são essencialmente compiladores simplificados com ênfase em análise estática.
DSLs e linguagens de configuração: Muitos sistemas usam linguagens específicas de domínio. Você agora sabe projetar e implementar DSLs efetivamente.
Processamento de linguagem natural: Embora muito mais complexo, NLP compartilha conceitos com compiladores - parsing, análise semântica, e transformação estruturada.
Sistemas de template e geração de código: Geradores que transformam especificações de alto nível em código são essencialmente compiladores especializados.
Interpretadores e REPLs: Sistemas interativos como Jupyter notebooks ou shells de linguagens são variações de interpretadores que você pode construir.
Próximos Passos em sua Educação
Esta disciplina forneceu fundações sólidas, mas o campo de compiladores e ferramentas de linguagem é vasto. Direções para aprofundamento incluem:
Otimizações avançadas: Estude técnicas sofisticadas como loop transformations, vectorization, e análise de aliasing que compiladores modernos implementam.
Linguagens de baixo nível: Explore geração de código de máquina real e otimizações arquitetura-específicas estudando backends de compiladores como GCC ou LLVM detalhadamente.
Análise estática avançada: Técnicas como abstract interpretation e symbolic execution permitem verificação profunda de propriedades de programas.
Concurrent e parallel languages: Compiladores para linguagens concorrentes apresentam desafios únicos em análise e otimização.
Verificação formal: Compiladores verificados formalmente como CompCert garantem matematicamente que compilação preserva semântica.
📚 Recursos Adicionais
Documentação Oficial
Language Server Protocol Specification: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
VS Code Extension API: https://code.visualstudio.com/api
VS Code Language Extensions Guide: https://code.visualstudio.com/api/language-extensions/overview
Implementações de Referência
TypeScript Language Server: https://github.com/typescript-language-server/typescript-language-server
Rust Analyzer: https://github.com/rust-lang/rust-analyzer
Python Language Server: https://github.com/python-lsp/python-lsp-server
Ferramentas e Bibliotecas
vscode-languageclient: Biblioteca para criar clientes LSP em extensões VS Code
vscode-languageserver: Biblioteca para criar servidores LSP em Node.js
lsp-types: Definições de tipos TypeScript para protocolo LSP
tree-sitter: Parser incremental de alta performance para muitas linguagens
Artigos e Tutoriais
Implementing a Language Server: https://langserver.org/
Tree-sitter Documentation: https://tree-sitter.github.io/tree-sitter/
VS Code Extension Samples: https://github.com/microsoft/vscode-extension-samples
✅ Checklist de Implementação
🎯 Guia Prático para Integração LSP
Use este checklist para guiar implementação sistemática de servidor LSP para seu compilador:
Fase 1: Fundação
Implementar parsing incremental básico que reutiliza partes não modificadas da AST anterior. Testar com documentos de vários tamanhos para validar ganhos de performance. Implementar tolerância a erros para que parser nunca falhe completamente, mesmo com código sintaticamente incorreto.
Fase 2: Servidor LSP Mínimo
Implementar comunicação JSON-RPC básica através de stdio. Responder a mensagens initialize e initialized corretamente. Implementar sincronização de documentos (didOpen, didChange, didClose). Enviar diagnósticos básicos (erros sintáticos) proativamente quando documentos mudam.
Fase 3: Capacidades de Navegação
Implementar textDocument/definition para go-to-definition. Implementar textDocument/references para find-references. Implementar textDocument/documentSymbol para outline view. Implementar textDocument/hover para mostrar tipos e documentação.
Fase 4: Assistência de Edição
Implementar textDocument/completion com sugestões contextuais básicas. Implementar textDocument/signatureHelp para mostrar parâmetros de funções. Implementar textDocument/codeAction com quick fixes simples. Implementar textDocument/formatting se compilador suporta formatação.
Fase 5: Extensão VS Code
Criar estrutura básica da extensão com package.json apropriado. Implementar cliente LSP que inicia e comunica com servidor. Adicionar gramática TextMate para coloração sintática básica. Configurar language configuration para auto-closing pairs e indentação.
Fase 6: Polimento
Implementar semantic tokens para coloração semântica rica. Otimizar performance com caching agressivo. Adicionar configurações para usuários customizarem comportamento. Escrever documentação completa e exemplos de uso. Criar suite de testes para validar funcionalidade LSP.
🌈 Mensagem Final de Encerramento
Você Chegou ao Fim, mas é Apenas o Começo!
Chegamos ao final de quinze semanas extraordinárias. Você começou esta jornada com curiosidade sobre como linguagens de programação funcionam. Termina com conhecimento profundo que poucos possuem e com um compilador completo que demonstra tangibly suas capacidades.
O compilador que você construiu não é apenas um projeto acadêmico - é uma ferramenta real e funcional que processa código, detecta erros, gera executáveis, e integra-se com editores modernos. É algo que você pode mostrar com orgulho, usar efetivamente, e continuar evoluindo.
Mais importante que o compilador em si são as transformações que ocorreram em você durante este processo. Você desenvolveu formas de pensar sistematicamente sobre problemas complexos. Ganhou confiança de que pode dominar tópicos tecnicamente desafiadores através de estudo deliberado e prática persistente. Aprendeu a colaborar efetivamente em equipes enfrentando desafios genuínos.
O conhecimento que você adquiriu é atemporal. Embora ferramentas e linguagens específicas evoluam, os princípios fundamentais de compilação, análise de linguagens, e design de ferramentas permanecem constantes. Você agora possui fundação sólida para continuar aprendendo e evoluindo durante toda sua carreira.
Lembre-se sempre que compiladores são apenas uma aplicação dos conceitos que você dominou. As mesmas técnicas - análise estruturada, transformações sistemáticas, raciocínio formal - aplicam-se a incontáveis domínios de computação. Você tem ferramentas intelectuais poderosas que o servirão em qualquer desafio futuro.
Obrigado por sua dedicação, curiosidade, e persistência ao longo desta jornada. Foi um privilégio guiá-lo através destes conceitos fascinantes. Continue explorando, continue construindo, e continue crescendo. O futuro da computação será moldado por pessoas como você que compreendem profundamente como sistemas realmente funcionam.
Parabéns por completar esta jornada extraordinária! 🎉🚀