LSP Server Básico em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir um servidor LSP (Language Server Protocol) básico em Zig para uma linguagem toy. O servidor vai comunicar via stdin/stdout com editores como VS Code, fornecendo diagnósticos, autocompletar e hover.
O Que Vamos Construir
Nosso LSP server vai:
- Implementar o protocolo LSP via JSON-RPC sobre stdin/stdout
- Responder a
initialize,textDocument/didOpen,textDocument/didChange - Fornecer diagnósticos (erros e avisos) em tempo real
- Suportar autocompletar para palavras-chave da linguagem toy
- Retornar informações de hover para tokens reconhecidos
- Parsear uma linguagem simples com
let,print,if
Por Que Este Projeto?
O LSP é o protocolo que faz editores modernos “entenderem” código. Construir um servidor LSP nos ensina sobre protocolos de comunicação, parsing incremental e a ponte entre compiladores e ferramentas. Em Zig, fazemos isso com parsing manual de JSON e controle total sobre I/O.
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Conhecimento de I/O e stdin/stdout
- Familiaridade com parsing
Passo 1: Estrutura do Projeto
mkdir lsp-server-basico
cd lsp-server-basico
zig init
Passo 2: Protocolo JSON-RPC
O LSP usa JSON-RPC 2.0 sobre stdin/stdout. Cada mensagem tem um header Content-Length: N\r\n\r\n seguido de N bytes de JSON.
const std = @import("std");
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;
const json = std.json;
/// Lê uma mensagem LSP do stdin.
/// Formato: "Content-Length: N\r\n\r\nJSON_BODY"
fn lerMensagem(reader: anytype, buf: []u8) ![]const u8 {
// Ler header
var header_buf: [256]u8 = undefined;
var content_length: usize = 0;
while (true) {
const header_line = reader.readUntilDelimiter(&header_buf, '\n') catch |err| {
if (err == error.EndOfStream) return err;
return err;
};
const trimmed = mem.trim(u8, header_line, "\r\n ");
if (trimmed.len == 0) break; // fim dos headers
if (mem.startsWith(u8, trimmed, "Content-Length: ")) {
content_length = fmt.parseInt(usize, trimmed[16..], 10) catch 0;
}
}
if (content_length == 0 or content_length > buf.len) {
return error.InvalidLength;
}
// Ler corpo
const bytes_lidos = try reader.readAtLeast(buf[0..content_length], content_length);
_ = bytes_lidos;
return buf[0..content_length];
}
/// Envia uma mensagem LSP via stdout.
fn enviarMensagem(writer: anytype, corpo: []const u8) !void {
try writer.print("Content-Length: {d}\r\n\r\n{s}", .{ corpo.len, corpo });
}
/// Envia uma resposta JSON-RPC.
fn enviarResposta(writer: anytype, id: i64, result: []const u8) !void {
var buf: [65536]u8 = undefined;
const resp = fmt.bufPrint(&buf,
\\{{"jsonrpc":"2.0","id":{d},"result":{s}}}
, .{ id, result }) catch return;
try enviarMensagem(writer, resp);
}
/// Envia uma notificação (sem id).
fn enviarNotificacao(writer: anytype, metodo: []const u8, params: []const u8) !void {
var buf: [65536]u8 = undefined;
const notif = fmt.bufPrint(&buf,
\\{{"jsonrpc":"2.0","method":"{s}","params":{s}}}
, .{ metodo, params }) catch return;
try enviarMensagem(writer, notif);
}
Passo 3: Analisador da Linguagem Toy
Nossa linguagem toy suporta: let x = 42, print(x), if x > 0 { ... }.
/// Tipos de diagnóstico (erro, aviso, informação).
const Severidade = enum(u8) {
erro = 1,
aviso = 2,
informacao = 3,
dica = 4,
};
/// Um diagnóstico encontrado no código.
const Diagnostico = struct {
linha: u32,
coluna_inicio: u32,
coluna_fim: u32,
mensagem: [256]u8,
mensagem_len: usize,
severidade: Severidade,
};
/// Palavras-chave da linguagem toy.
const PALAVRAS_CHAVE = [_][]const u8{
"let", "print", "if", "else", "while", "fn", "return", "true", "false",
};
/// Analisa o documento e retorna diagnósticos.
fn analisar(conteudo: []const u8, diagnosticos: []Diagnostico) usize {
var count: usize = 0;
var linha: u32 = 0;
var linhas = mem.splitScalar(u8, conteudo, '\n');
while (linhas.next()) |lin| {
const trimmed = mem.trim(u8, lin, " \t\r");
// Verificar linhas muito longas
if (trimmed.len > 80 and count < diagnosticos.len) {
diagnosticos[count] = criarDiagnostico(
linha, 80, @intCast(trimmed.len),
"Linha muito longa (>{d} caracteres)", .{80},
.aviso,
);
count += 1;
}
// Verificar 'let' sem '='
if (mem.startsWith(u8, trimmed, "let ") and !mem.containsAny(u8, trimmed, "=")) {
if (count < diagnosticos.len) {
diagnosticos[count] = criarDiagnostico(
linha, 0, @intCast(trimmed.len),
"Declaracao 'let' sem atribuicao (faltando '=')", .{},
.erro,
);
count += 1;
}
}
// Verificar 'print' sem parênteses
if (mem.startsWith(u8, trimmed, "print ") and !mem.containsAny(u8, trimmed, "(")) {
if (count < diagnosticos.len) {
diagnosticos[count] = criarDiagnostico(
linha, 0, 5,
"print requer parenteses: print(valor)", .{},
.erro,
);
count += 1;
}
}
// Verificar parênteses não fechados
var abertos: i32 = 0;
for (trimmed) |c| {
if (c == '(') abertos += 1;
if (c == ')') abertos -= 1;
}
if (abertos != 0 and count < diagnosticos.len) {
diagnosticos[count] = criarDiagnostico(
linha, 0, @intCast(trimmed.len),
"Parenteses desbalanceados", .{},
.erro,
);
count += 1;
}
linha += 1;
}
return count;
}
fn criarDiagnostico(
linha: u32,
col_inicio: u32,
col_fim: u32,
comptime format: []const u8,
args: anytype,
severidade: Severidade,
) Diagnostico {
var d: Diagnostico = undefined;
d.linha = linha;
d.coluna_inicio = col_inicio;
d.coluna_fim = col_fim;
d.severidade = severidade;
const msg = fmt.bufPrint(&d.mensagem, format, args) catch "";
d.mensagem_len = msg.len;
return d;
}
Passo 4: Handlers LSP
/// Gera o JSON de diagnósticos para o LSP.
fn gerarDiagnosticosJSON(
uri: []const u8,
diagnosticos: []const Diagnostico,
count: usize,
buf: []u8,
) []const u8 {
var fbs = io.fixedBufferStream(buf);
const writer = fbs.writer();
writer.print(
\\{{"uri":"{s}","diagnostics":[
, .{uri}) catch return "";
for (diagnosticos[0..count], 0..) |d, i| {
if (i > 0) writer.writeAll(",") catch {};
writer.print(
\\{{"range":{{"start":{{"line":{d},"character":{d}}},"end":{{"line":{d},"character":{d}}}}},"severity":{d},"source":"toy-lang","message":"{s}"}}
, .{
d.linha, d.coluna_inicio, d.linha, d.coluna_fim,
@intFromEnum(d.severidade),
d.mensagem[0..d.mensagem_len],
}) catch {};
}
writer.writeAll("]}") catch {};
return fbs.getWritten();
}
/// Gera a resposta de initialize.
fn respostaInitialize() []const u8 {
return
\\{"capabilities":{"textDocumentSync":1,"completionProvider":{"triggerCharacters":["."]},"hoverProvider":true},"serverInfo":{"name":"toy-lang-lsp","version":"0.1.0"}}
;
}
/// Gera itens de autocompletar.
fn gerarCompletion() []const u8 {
return
\\{"isIncomplete":false,"items":[
\\{"label":"let","kind":14,"detail":"Declaracao de variavel","insertText":"let ${1:nome} = ${2:valor}"},
\\{"label":"print","kind":3,"detail":"Imprimir valor","insertText":"print(${1:valor})"},
\\{"label":"if","kind":14,"detail":"Condicional","insertText":"if ${1:cond} {\n\t${2}\n}"},
\\{"label":"while","kind":14,"detail":"Loop while","insertText":"while ${1:cond} {\n\t${2}\n}"},
\\{"label":"fn","kind":3,"detail":"Funcao","insertText":"fn ${1:nome}(${2:args}) {\n\t${3}\n}"},
\\{"label":"return","kind":14,"detail":"Retornar valor","insertText":"return ${1:valor}"},
\\{"label":"true","kind":21,"detail":"Booleano verdadeiro"},
\\{"label":"false","kind":21,"detail":"Booleano falso"}
\\]}
;
}
Passo 5: Loop Principal do Servidor
pub fn main() !void {
const stderr = io.getStdErr().writer();
const stdin_reader = io.getStdIn().reader();
const stdout_writer = io.getStdOut().writer();
try stderr.print("[toy-lsp] Servidor LSP iniciado\n", .{});
var buf_msg: [65536]u8 = undefined;
var buf_diag: [65536]u8 = undefined;
var documento_uri: [512]u8 = undefined;
var documento_uri_len: usize = 0;
var documento_conteudo: [1024 * 1024]u8 = undefined;
var documento_conteudo_len: usize = 0;
while (true) {
const mensagem = lerMensagem(stdin_reader, &buf_msg) catch |err| {
if (err == error.EndOfStream) break;
try stderr.print("[toy-lsp] Erro leitura: {}\n", .{err});
continue;
};
try stderr.print("[toy-lsp] Recebido: {d} bytes\n", .{mensagem.len});
// Extrair método e id do JSON (parsing simplificado)
var metodo: []const u8 = "";
var id: i64 = -1;
if (mem.indexOf(u8, mensagem, "\"method\":\"")) |pos| {
const inicio = pos + 10;
if (mem.indexOfPos(u8, mensagem, inicio, "\"")) |fim| {
metodo = mensagem[inicio..fim];
}
}
if (mem.indexOf(u8, mensagem, "\"id\":")) |pos| {
const inicio = pos + 5;
var fim = inicio;
while (fim < mensagem.len and ((mensagem[fim] >= '0' and mensagem[fim] <= '9') or mensagem[fim] == '-')) : (fim += 1) {}
id = fmt.parseInt(i64, mensagem[inicio..fim], 10) catch -1;
}
try stderr.print("[toy-lsp] Metodo: {s}, ID: {d}\n", .{ metodo, id });
// Despachar por método
if (mem.eql(u8, metodo, "initialize")) {
try enviarResposta(stdout_writer, id, respostaInitialize());
} else if (mem.eql(u8, metodo, "initialized")) {
try stderr.print("[toy-lsp] Inicializado com sucesso\n", .{});
} else if (mem.eql(u8, metodo, "textDocument/didOpen") or
mem.eql(u8, metodo, "textDocument/didChange"))
{
// Extrair URI do documento
if (mem.indexOf(u8, mensagem, "\"uri\":\"")) |pos| {
const inicio = pos + 7;
if (mem.indexOfPos(u8, mensagem, inicio, "\"")) |fim| {
const uri = mensagem[inicio..fim];
@memcpy(documento_uri[0..uri.len], uri);
documento_uri_len = uri.len;
}
}
// Extrair conteúdo do texto
if (mem.indexOf(u8, mensagem, "\"text\":\"")) |pos| {
const inicio = pos + 8;
// Encontrar o fim da string JSON (aspas não escapada)
var fim = inicio;
while (fim < mensagem.len) : (fim += 1) {
if (mensagem[fim] == '"' and (fim == 0 or mensagem[fim - 1] != '\\')) break;
}
const texto = mensagem[inicio..fim];
const len = @min(texto.len, documento_conteudo.len);
@memcpy(documento_conteudo[0..len], texto[0..len]);
documento_conteudo_len = len;
}
// Analisar e enviar diagnósticos
var diagnosticos: [64]Diagnostico = undefined;
const count = analisar(documento_conteudo[0..documento_conteudo_len], &diagnosticos);
const diag_json = gerarDiagnosticosJSON(
documento_uri[0..documento_uri_len],
&diagnosticos,
count,
&buf_diag,
);
try enviarNotificacao(stdout_writer, "textDocument/publishDiagnostics", diag_json);
try stderr.print("[toy-lsp] {d} diagnosticos enviados\n", .{count});
} else if (mem.eql(u8, metodo, "textDocument/completion")) {
try enviarResposta(stdout_writer, id, gerarCompletion());
} else if (mem.eql(u8, metodo, "textDocument/hover")) {
const hover_result =
\\{"contents":{"kind":"markdown","value":"**Toy Language**\n\nServidor LSP construido em Zig."}}
;
try enviarResposta(stdout_writer, id, hover_result);
} else if (mem.eql(u8, metodo, "shutdown")) {
try enviarResposta(stdout_writer, id, "null");
} else if (mem.eql(u8, metodo, "exit")) {
break;
}
}
try stderr.print("[toy-lsp] Servidor encerrado\n", .{});
}
Testes
test "analisar - let sem igual" {
var diagnosticos: [64]Diagnostico = undefined;
const count = analisar("let x\n", &diagnosticos);
try std.testing.expect(count >= 1);
try std.testing.expectEqual(Severidade.erro, diagnosticos[0].severidade);
}
test "analisar - print sem parenteses" {
var diagnosticos: [64]Diagnostico = undefined;
const count = analisar("print hello\n", &diagnosticos);
try std.testing.expect(count >= 1);
}
test "analisar - codigo valido sem diagnosticos" {
var diagnosticos: [64]Diagnostico = undefined;
const count = analisar("let x = 42\nprint(x)\n", &diagnosticos);
try std.testing.expectEqual(@as(usize, 0), count);
}
Compilando e Executando
zig build
# Para testar, configure no VS Code (.vscode/settings.json):
# {
# "toy-lang.lsp.path": "./zig-out/bin/lsp-server-basico"
# }
# Ou teste manualmente:
echo 'Content-Length: 58\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | zig build run
Conceitos Aprendidos
- Protocolo LSP com JSON-RPC sobre stdin/stdout
- Análise de código para diagnósticos em tempo real
- Autocompletar com itens e snippets
- Parsing de JSON simplificado para extração de campos
- Comunicação por headers (Content-Length)
Próximos Passos
- Explore o Compilador de Expressões para parsing avançado
- Veja o Protocolo Binário para outro protocolo
- Consulte a documentação do LSP
- Construa o Regex Engine para análise de padrões