LSP Server Básico em Zig — Tutorial Passo a Passo

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

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

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.