🔍 LSP, autocomplete và diagnostics

By 3T1C

Module 1 đã cho bạn bộ khung init.lua, lazy.nvim và leader key. Bây giờ ta gắn phần biến Neovim thành IDE thật: một LSP client biết đọc code, autocomplete lấy dữ liệu từ chính server đó, và diagnostics hiện ngay trong buffer.

Bạn sẽ làm được gì sau bài này

  • Tách được 3 tầng của một LSP setup: server binary, LSP client của Neovim, lớp UI (completion + diagnostics).
  • Viết spec lazy.nvim cho mason.nvim, mason-lspconfig.nvim, nvim-lspconfig và nvim-cmp.
  • Đọc diagnostics đúng cách và tự chẩn đoán khi server không attach trên Windows 11.

Ba tầng của một LSP setup

Trước khi gõ config, hãy nhìn đúng chỗ. Hầu hết lỗi "LSP không chạy" là do nhầm tầng.

flowchart TD
  Mason["📦<br/>mason.nvim"] --> Bin["⚙️<br/>Server binary"]
  Bin --> Enable["🔌<br/>vim.lsp.enable"]
  Enable --> Buf["📝<br/>Buffer + LSP client"]
  Buf --> UI["💬<br/>cmp + diagnostics"]
  • Tầng binary: lua-language-server, ts_ls, pyright là chương trình riêng, phải tồn tại trên đĩa.
  • Tầng client: Neovim nói chuyện với binary qua JSON-RPC. Đây là phần bạn bật bằng vim.lsp.enable.
  • Tầng UI: nvim-cmp hiển thị gợi ý, vim.diagnostic vẽ gạch chân và virtual text. UI không tự sinh dữ liệu, nó chỉ hiển thị cái client nhận được.

Dựng từng bước trên cấu hình capstone

Bước 1: cài server bằng mason

Tạo lua/plugins/lsp.lua:

return {
  { "williamboman/mason.nvim", opts = {} },
  {
    "williamboman/mason-lspconfig.nvim",
    dependencies = { "williamboman/mason.nvim", "neovim/nvim-lspconfig" },
    opts = {
      ensure_installed = { "lua_ls", "ts_ls", "pyright" },
    },
  },
  {
    "neovim/nvim-lspconfig",
    event = { "BufReadPre", "BufNewFile" },
    dependencies = { "hrsh7th/cmp-nvim-lsp" },
    config = function()
      local capabilities = require("cmp_nvim_lsp").default_capabilities()

      for _, server in ipairs({ "lua_ls", "ts_ls", "pyright" }) do
        vim.lsp.config(server, { capabilities = capabilities })
      end

      vim.lsp.config("lua_ls", {
        settings = {
          Lua = {
            runtime = { version = "LuaJIT" },
            diagnostics = { globals = { "vim" } },
            workspace = { library = vim.api.nvim_get_runtime_file("", true) },
          },
        },
      })

      vim.lsp.enable({ "lua_ls", "ts_ls", "pyright" })
    end,
  },
}

Lý do của từng dòng:

  • ensure_installed để mason tự cài khi mở Neovim lần đầu. Không có nó, bạn phải vào :Mason bấm tay.
  • default_capabilities() khai báo với server rằng client này hiểu snippet, completionItem/resolve, v.v. Thiếu bước này, completion vẫn hiện nhưng nghèo thông tin và không tự chèn import.
  • vim.lsp.config chỉ khai báo, vim.lsp.enable mới bật theo filetype. Tách hai việc này cho phép bạn merge cấu hình riêng mà không copy toàn bộ config gốc của nvim-lspconfig. Cách này áp dụng từ Neovim 0.11; với bản cũ hơn, bạn dùng require("lspconfig").lua_ls.setup{...}.
  • event = { "BufReadPre", "BufNewFile" } để plugin nạp đúng lúc mở file đầu tiên, không làm chậm startup.

Bước 2: autocomplete

Tạo lua/plugins/cmp.lua:

return {
  {
    "hrsh7th/nvim-cmp",
    event = "InsertEnter",
    dependencies = {
      "hrsh7th/cmp-nvim-lsp",
      "hrsh7th/cmp-buffer",
      "hrsh7th/cmp-path",
      "L3MON4D3/LuaSnip",
      "saadparwaiz1/cmp_luasnip",
    },
    config = function()
      local cmp = require("cmp")
      cmp.setup({
        snippet = {
          expand = function(args) require("luasnip").lsp_expand(args.body) end,
        },
        mapping = cmp.mapping.preset.insert({
          ["<C-Space>"] = cmp.mapping.complete(),
          ["<CR>"] = cmp.mapping.confirm({ select = false }),
          ["<C-e>"] = cmp.mapping.abort(),
          ["<Tab>"] = cmp.mapping.select_next_item(),
          ["<S-Tab>"] = cmp.mapping.select_prev_item(),
        }),
        sources = cmp.config.sources(
          { { name = "nvim_lsp" }, { name = "luasnip" } },
          { { name = "buffer" }, { name = "path" } }
        ),
      })
    end,
  },
}

Thứ tự source quan trọng: nvim_lsp đứng trước buffer để gợi ý có ngữ nghĩa thắng gợi ý "đoán theo chữ đã gõ". <CR> đặt select = false để Enter chỉ chọn khi bạn đã di chuyển bằng Tab, tránh việc gõ Enter xuống dòng bị nuốt.

Bước 3: diagnostics và keymap theo buffer

Tạo lua/config/lsp.lua, gọi từ init.lua sau require("lazy").setup(...):

vim.diagnostic.config({
  virtual_text = { spacing = 2, prefix = "●" },
  severity_sort = true,
  update_in_insert = false,
  underline = true,
  float = { border = "rounded", source = true },
})

vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(ev)
    local map = function(mode, lhs, rhs, desc)
      vim.keymap.set(mode, lhs, rhs, { buffer = ev.buf, desc = desc })
    end
    map("n", "gd", vim.lsp.buf.definition, "Go to definition")
    map("n", "K", vim.lsp.buf.hover, "Hover docs")
    map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")
    map("n", "<leader>ca", vim.lsp.buf.code_action, "Code action")
    map("n", "<leader>e", vim.diagnostic.open_float, "Diagnostic float")
    map("n", "]d", function() vim.diagnostic.jump({ count = 1 }) end, "Next diagnostic")
    map("n", "[d", function() vim.diagnostic.jump({ count = -1 }) end, "Prev diagnostic")
    map("n", "<leader>dq", vim.diagnostic.setqflist, "Diagnostics to quickfix")
  end,
})

severity_sort = true đẩy ERROR lên trước WARN trong float và quickfix. update_in_insert = false giữ diagnostics đứng yên khi bạn đang gõ, tránh nhấp nháy và tránh việc server bắn hàng trăm gói tin mỗi phím. Nếu bạn dùng Neovim cũ hơn 0.11, thay vim.diagnostic.jump bằng vim.diagnostic.goto_next / goto_prev.

Ba mức bạn sẽ gặp: ERROR, WARN, INFO, HINT. Chỉ ERROR và WARN đáng xử lý ngay; HINT thường là gợi ý style, đừng để nó chiếm virtual text.

Bước 4: xác nhận nó sống

:Mason
:checkhealth vim.lsp
:lua =vim.lsp.get_clients({ bufnr = 0 })

Lệnh thứ hai là nguồn chân lý trên Neovim 0.11 (:LspInfo đã bị bỏ). Lệnh thứ ba phải trả về một bảng không rỗng khi bạn mở file .lua, .ts hoặc .py.

Thực hành trên cấu hình capstone

Bài 1. Thêm gopls vào danh sách server và bật nó, sau đó kiểm tra nó chỉ attach cho buffer Go.

Gợi ý

Thêm "gopls" vào ensure_installed và vào cả hai lời gọi vim.lsp.config / vim.lsp.enable. Mở một file .go, chạy :lua =vim.lsp.get_clients({ bufnr = 0 }) và đọc trường name. Mở một file .lua và chạy lại: danh sách phải khác. Nếu gopls chưa có, :Mason sẽ hiện trạng thái đang cài.

Bài 2. Đổi <leader>e thành mở float với severity_sort và thử rename một biến bằng <leader>rn trong project thật của bạn.

Gợi ý

Rename cần server đã attach và project có root_dir xác định (thường là thư mục có .git). Nếu <leader>rn báo "no LSP client", quay lại Bước 4 trước khi nghi ngờ keymap. Sau khi rename, mở quickfix bằng <leader>dq để chắc không còn tham chiếu cũ.

Bài 3. Tắt virtual text cho riêng mức HINT, giữ nguyên cho ERROR và WARN.

Gợi ý

vim.diagnostic.config nhận bảng con virtual_text với hàm format. Trả về nil cho HINT để không vẽ, trả về chuỗi cho các mức còn lại. Đây là bài tập đọc API, không phải copy: mở :help vim.diagnostic.config và tìm severity.

Khi language server im lặng trên Windows 11

Triệu chứng Kiểm tra trước tiên
:Mason báo package failed :lua print(vim.fn.exepath("npm")). Mason gọi cmd.exe để chạy npm; nếu npm không nằm trong PATH của tiến trình Neovim, cài đặt thất bại dù terminal ngoài chạy được
Server đã cài nhưng get_clients rỗng Filetype có đúng không (:set ft?), và vim.lsp.enable đã chạy chưa (kiểm tra plugin có thật sự load)
Attach được nhưng không có gợi ý Thiếu capabilities, hoặc cmp chưa nạp source nvim_lsp
Chậm bất thường lần mở đầu Windows Defender quét %LOCALAPPDATA%\nvim-data; thêm exclusion cho thư mục này
Server crash khi mở project Project nằm trong thư mục đồng bộ OneDrive hoặc đường dẫn có ký tự lạ; thử copy ra ổ local

Khi bí, đọc log:

:lua print(vim.lsp.log.get_filename())
:lua vim.lsp.set_log_level("debug")

File log nằm trong stdpath("log), thường là %LOCALAPPDATA%\nvim-data\log\nvim.log. Mở file, tìm các dòng [ERROR] hoặc [WARN] quanh thời điểm bạn mở file nguồn. Chín trên mười lỗi "LSP không chạy" trên Windows để lại dấu vết rõ ràng ở đây: sai cmd, thiếu root_dir, hoặc server thoát vì không tìm thấy runtime.

Một mẹo nhỏ khi debug: chạy riêng server bằng tay để tách lỗi Neovim khỏi lỗi môi trường.

# ví dụ với lua-language-server đã cài qua Mason
& "$env:LOCALAPPDATA\nvim-data\mason\bin\lua-language-server.cmd" --version

Nếu lệnh này fail ngay trong PowerShell, vấn đề nằm ở môi trường, không nằm ở config của bạn.

Giữ LSP nhẹ, nhanh và không tự phá mình

Vài quy tắc tôi dùng sau nhiều lần cấu hình lại từ đầu:

  • Cài server theo nhu cầu, không theo danh sách. Mỗi server là một tiến trình thường trú. Ba server cho ba ngôn ngữ bạn thật sự dùng tốt hơn mười server cho mười ngôn ngữ bạn mở một lần mỗi tháng.
  • Đặt keymap LSP trong LspAttach, không đặt ở init.lua gốc. Keymap chỉ nên tồn tại trong buffer có server. Đặt toàn cục sẽ khiến <leader>rn báo lỗi khó hiểu ở buffer không có LSP.
  • Tách cấu hình theo file. lua/plugins/lsp.lua chứa plugin spec, lua/config/lsp.lua chứa keymap và vim.diagnostic.config. Khi hỏng, bạn biết ngay mở file nào.
  • Chỉ bật debug khi cần. vim.lsp.set_log_level("debug") ghi log rất nhanh; nhớ đặt lại "warn" sau khi xử lý xong.
  • Coi diagnostics là tín hiệu, không phải mệnh lệnh. Cảnh báo từ server đôi khi là quy ước style của project khác với của bạn. Sửa hay bỏ qua là quyết định của bạn, nhưng phải là quyết định có ý thức.

Bảng tra nhanh ba lệnh bạn sẽ dùng nhiều nhất:

Lệnh Dùng khi nào
:checkhealth vim.lsp Muốn biết Neovim có thấy server và có attach đúng không
:LspInfo Muốn xem nhanh client nào đang chạy trên buffer hiện tại
:Mason Muốn cài, xoá, cập nhật server và xem log cài đặt

Chốt lại và sang Telescope

Ba điều cần mang theo: LSP gồm ba mảnh rời (server do Mason cài, client do Neovim quản, autocomplete do cmp lo), capabilities là sợi dây nối giữa client và cmp, và diagnostics chỉ hữu ích khi bạn cấu hình nó theo cách mắt bạn đọc được. Trên Windows 11, phần lớn lỗi nằm ở PATH và quyền truy cập file, không nằm ở Lua.

Khi đã có gợi ý code và jump, bước tiếp theo là di chuyển trong dự án cho nhanh: bài sau ta dựng Telescope để tìm file và grep, và tận dụng luôn LSP để tìm symbol thay vì chỉ grep text.

Neovim không tự cài language server; công cụ lo việc đó trong cấu hình của chúng ta là _____. Neovim 0.11 trở đi bật một server đã khai báo trong lsp/ bằng hàm _____. Bảng tính năng mà client gửi cho server để cmp biết server hỗ trợ gì có tên là _____. Giao diện hiển thị lỗi, cảnh báo inline trong buffer được điều khiển bởi _____. Trên Windows 11, Mason thường cài server thất bại vì npm không có trong _____ của tiến trình Neovim.

Front: Ba mảnh rời của một thiết lập LSP trong Neovim là gì? Back: Language server (Mason cài), LSP client (Neovim quản), và lớp autocomplete (nvim-cmp).

Front: Trong Neovim 0.11+, bật một server đã khai báo trong thư mục lsp/ bằng lệnh nào? Back: vim.lsp.enable("tên_server").

Front: Mason làm gì và không làm gì? Back: Mason cài/xoá/cập nhật server bên ngoài Neovim; nó không thay Neovim khởi động client.

Front: Trường capabilities trong cấu hình client dùng để làm gì? Back: Cho server biết client hỗ trợ tính năng nào, để cmp nhận đúng completion, snippet và signature help.

Front: Lệnh nào cho biết buffer hiện tại có LSP client nào đang attach? Back: :LspInfo, hoặc :lua print(#vim.lsp.get_clients({bufnr = 0})).

Front: Bốn mức severity của diagnostics, từ nặng nhất đến nhẹ nhất? Back: ERROR, WARN, INFO, HINT.

Front: Cấu hình hiển thị diagnostics (virtual text, sign, float) đặt ở đâu? Back: Trong vim.diagnostic.config, gọi một lần trong config, không đặt trong LspAttach.

Front: Vì sao keymap LSP nên khai báo trong LspAttach? Back: Để keymap chỉ tồn tại ở buffer thật sự có server, tránh lỗi khó hiểu ở buffer thường.

Front: Lấy đường dẫn file log của LSP bằng lệnh nào? Back: :lua print(vim.lsp.log.get_filename()), rồi :lua vim.lsp.set_log_level("debug") để ghi chi tiết.

Front: Triệu chứng Mason báo cài package thất bại trên Windows 11 thường do đâu? Back: npm hoặc runtime không nằm trong PATH của tiến trình Neovim, dù terminal ngoài vẫn chạy được.

Front: Server đã cài nhưng không attach: kiểm tra hai thứ gì trước? Back: Filetype của buffer (:set ft?) và việc plugin khai báo vim.lsp.enable đã thật sự load chưa.

Front: Vì sao lần mở file đầu tiên chậm bất thường trên Windows 11? Back: Windows Defender quét %LOCALAPPDATA%\nvim-data; thêm exclusion cho thư mục này để hết bị quét mỗi lần.