📁 Bộ khung init.lua và cách tổ chức file cấu hình trên Windows 11

By 3T1C

Bộ khung init.lua và cách tổ chức file cấu hình trên Windows 11

Lần trước bạn dừng cuộc ở đúng chỗ này: mở Neovim ra, định config, nhưng không chắc file cấu hình nằm ở đâu và nên bắt đầu từ file nào. Bài này giải quyết đúng khúc đó. Bạn không cần học lại vim motion, chỉ cần dựng đúng khung thư mục và hiểu vì sao nó được tổ chức như vậy, để những bài sau chỉ việc đổ thêm nội dung vào.

Sau bài này, bạn sẽ:

  • In ra được đường dẫn cấu hình chuẩn bằng stdpath
  • Tạo init.lua cùng cấu trúc lua/config/ và lua/plugins/
  • Giải thích được require('config.options') ánh xạ tới file nào
  • Nêu được lý do tách file thay vì nhồi một init.lua dài
  • Tránh được các bẫy đường dẫn đặc thù trên Windows 11

Neovim đọc cấu hình ở đâu trên Windows 11

Đừng đoán đường dẫn, hãy để Neovim tự nói:

:echo stdpath('config')
:echo stdpath('data')
Biến Ý nghĩa Đường dẫn trên Windows 11
stdpath('config') Nơi chứa init.lua, lua/ C:/Users/<tên>/AppData/Local/nvim
stdpath('data') Plugin, shada, swap C:/Users/<tên>/AppData/Local/nvim-data
stdpath('state') Trạng thái runtime Trùng data trên Windows

Hai điểm khiến người mới mất thời gian nhất:

  1. Neovim dùng %LOCALAPPDATA%, không phải %APPDATA%. Nếu bạn tạo AppData\Roaming\nvim\init.lua thì Neovim sẽ không bao giờ đọc file đó.
  2. Tên thư mục phải đúng là nvim, không phải neovim, không phải .config/nvim.

Vì stdpath() trả về đường dẫn phụ thuộc môi trường, hãy luôn dùng nó trong Lua thay vì hardcode C:/Users/.... Đó là lý do cấu hình của bạn chạy được trên cả máy đồng nghiệp.

Tip: Trong PowerShell: explorer $env:LOCALAPPDATA\nvim mở thẳng thư mục cấu hình. Dùng nhiều lần trong ngày thì đáng để tạo một function.

init.lua và thứ tự nạp

Neovim tìm file khởi động ở gốc stdpath('config'). Nếu cả init.vim và init.lua cùng tồn tại, Neovim nạp _____ và bỏ qua file còn lại. Vì vậy khi chuyển sang Lua, bạn nên xóa init.vim để khỏi bối rối về sau.

init.lua đóng vai trò "mục lục", không phải nơi chứa toàn bộ cấu hình:

-- %LOCALAPPDATA%/nvim/init.lua
require('config.options')  -- lua/config/options.lua
require('config.keymaps')  -- lua/config/keymaps.lua
require('config.lazy')     -- lua/config/lazy.lua (bài sau)

Thứ tự này không tùy tiện. Options phải có trước để plugin đọc được giá trị đúng; keymaps trước để leader key tồn tại khi plugin gắn keymap của chúng; plugin manager sau cùng.

flowchart TD
    A["🚀<br/>init.lua"] --> B["⚙️<br/>config.options"]
    B --> C["⌨️<br/>config.keymaps"]
    C --> D["📦<br/>config.lazy"]

Ví dụ nội dung lua/config/options.lua:

-- lua/config/options.lua
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4
vim.opt.termguicolors = true

vim.opt tự chuyển đổi kiểu (boolean, list, number) nên an toàn hơn vim.o khi bạn không chắc option nhận dạng dữ liệu gì.

Note: Từ Neovim 0.10 trở lên, vim.opt.clipboard = 'unnamedplus' dùng được clipboard hệ thống trực tiếp trên Windows. Bản cũ hơn cần provider ngoài như win32yank.

Bài tập 1. Bạn vừa cài Neovim, chưa có gì trong máy. Viết hai lệnh để xác định chính xác mình phải tạo file ở đâu, và nêu đường dẫn đầy đủ nếu tên user Windows là nguyen.an.

Xem gợi ý

Chạy :echo stdpath('config') và :echo stdpath('data'). Với user nguyen.an, cấu hình nằm ở C:/Users/nguyen.an/AppData/Local/nvim, dữ liệu ở C:/Users/nguyen.an/AppData/Local/nvim-data. Đừng gõ tay đường dẫn này vào config; dùng stdpath() trong Lua.

Tách file: lua/, require và runtimepath

Đây là phần cốt lõi. require không biết gì về init.lua; nó tìm module trong các thư mục lua/ nằm trên _____. Nghĩa là thư mục lua/ ngay dưới stdpath('config') đã tự động nằm trong tầm với.

Cấu trúc nên dựng:

%LOCALAPPDATA%/nvim/
  init.lua
  lua/
    config/
      options.lua
      keymaps.lua
      lazy.lua
    plugins/

Quy tắc ánh xạ rất máy móc, không có phép thuật:

  • require('config.options') nạp file lua/config/options.lua
  • Dấu . thay cho /, bỏ đuôi .lua
  • Base path là lua/, không phải thư mục gốc

Hai hệ quả thực tế:

  • Sửa nội dung một module đã require rồi :source $MYVIMRC sẽ không nạp lại module đó, vì Lua cache trong package.loaded. Muốn thấy thay đổi, restart Neovim, hoặc :luafile % khi đang mở chính file đó.
  • Nếu require báo lỗi "module not found", gần như luôn là sai đường dẫn thư mục, không phải sai cú pháp Lua.

Bài tập 2. Tạo lua/config/keymaps.lua chứa một keymap lưu file bằng <leader>w, rồi nạp nó từ init.lua.

Xem gợi ý

-- lua/config/keymaps.lua
vim.keymap.set('n', '<leader>w', '<cmd>write<cr>', { desc = 'Lưu file' })

Trong init.lua thêm require('config.keymaps'). Nếu chưa đặt leader key, <leader> mặc định là \; bài 1.3 sẽ đặt vim.g.mapleader tử tế. Lưu ý vim.g.mapleader phải được gán trước khi keymap được tạo.

Vì sao không nhồi hết vào một init.lua

Bạn hoàn toàn có thể viết một file 1500 dòng và nó vẫn chạy. Nhưng khi lỗi xảy ra lúc 23h trước deadline, bạn sẽ trả giá:

  • Khoanh vùng lỗi nhanh. Neovim báo lỗi kèm số dòng; nếu lỗi ở lua/config/options.lua thì phạm vi nghi ngờ chỉ còn vài chục dòng.
  • Git diff sạch. Thêm Telescope không làm diff loang ra toàn bộ file, nên review và revert dễ hơn nhiều.
  • Thứ tự nạp tường minh. init.lua đọc như mục lục, ai vào sau cũng hiểu cái gì chạy trước.
  • Tách theo plugin. lua/plugins/ cho phép mỗi plugin một file, đúng cách lazy.nvim sẽ import ở bài sau.
  • Dễ vô hiệu hóa. Comment một dòng require là tắt cả một nhóm cấu hình, thay vì đi xóa từng block.

Những bẫy đường dẫn riêng của Windows 11

  • Trong string Lua, \ là ký tự escape. "C:\Users" sẽ lỗi vì \U không hợp lệ. Dùng / ("C:/Users") hoặc escape kép ("C:\\Users").
  • Tên user có dấu tiếng Việt hoặc dấu cách có thể làm hỏng một số tool biên dịch bên ngoài. Nếu gặp lỗi lạ khi cài LSP server, kiểm tra đường dẫn trước khi nghi plugin.
  • File cấu hình nên lưu với LF. Trộn CRLF thường không sao, nhưng script shell gọi từ Neovim trên WSL thì có sao.

Ba lỗi làm hỏng khung ngay từ đầu

  1. Tạo config trong AppData\Roaming. Neovim không đọc ở đó. Luôn xác nhận bằng :echo stdpath('config') trước khi viết dòng Lua đầu tiên.
  2. Đặt init.lua vào trong lua/. Nó phải nằm ở gốc stdpath('config'). lua/init.lua chỉ là một module bình thường, không được nạp tự động.
  3. Sửa file rồi :source $MYVIMRC và tưởng nó chưa ăn. Với module đã cache, bạn phải restart. Đây là hiểu nhầm tốn thời gian nhất của người mới chuyển từ init.vim sang Lua.

Một quy tắc ngón tay: mỗi file dưới 100 dòng, mỗi file một trách nhiệm. Khi một file vượt 100 dòng, đó là tín hiệu nên tách.

Chốt lại

Bạn vừa có bộ khung chạy được: init.lua làm mục lục, lua/config/ chứa options và keymaps, lua/plugins/ để trống chờ plugin. Đường dẫn luôn lấy từ stdpath('config'), và mọi thứ nối với nhau qua require trên các thư mục lua/ trong runtimepath.

Bài tiếp theo đổ phần thân vào khung đó: cài lazy.nvim, viết spec plugin đầu tiên và bật lazy loading, cùng hướng mở rộng sang vim.pack.

Front: Lệnh nào in ra đường dẫn thư mục cấu hình Neovim? Back: :echo stdpath('config')

Front: stdpath('config') trỏ tới đâu trên Windows 11? Back: %LOCALAPPDATA%/nvim, thường là C:/Users/<tên>/AppData/Local/nvim

Front: stdpath('data') trên Windows 11 là gì? Back: %LOCALAPPDATA%/nvim-data, nơi chứa plugin, shada và swap file

Front: %APPDATA% (Roaming) có phải nơi Neovim đọc config không? Back: Không. Đây là bẫy phổ biến; Neovim dùng %LOCALAPPDATA%

Front: Nếu cả init.lua và init.vim cùng tồn tại thì Neovim nạp file nào? Back: init.lua, và bỏ qua init.vim

Front: require('config.options') nạp file nào? Back: lua/config/options.lua bên trong stdpath('config')

Front: require tìm module Lua dựa trên cơ sở nào? Back: Các thư mục lua/ nằm trên runtimepath

Front: Biến nào trỏ tới file cấu hình đang chạy? Back: $MYVIMRC; dùng :source $MYVIMRC để nạp lại

Front: Vì sao không viết C:\Users trong string Lua? Back: \ là ký tự escape trong Lua; dùng / hoặc escape kép \\

Front: Thứ tự require hợp lý trong init.lua? Back: options, rồi keymaps, rồi plugin manager (lazy)

Front: Vì sao tách config thành nhiều file nhỏ? Back: Dễ khoanh vùng lỗi, git diff sạch, thứ tự nạp rõ ràng, dễ tắt từng nhóm

Front: package.loaded ảnh hưởng gì khi sửa file trong lua/? Back: Module đã require bị cache, phải restart Neovim hoặc :luafile % mới thấy thay đổi

Front: Thư mục lua/plugins/ trong khung dùng để làm gì? Back: Chứa spec plugin, mỗi plugin một file, để lazy.nvim import ở bài sau

Bạn còn nhớ: init.lua phải nằm ở gốc, còn đường dẫn cấu hình thì luôn hỏi Neovim qua _____ thay vì gõ tay. require('config.keymaps') ánh xạ tới _____, và require chỉ nạp một lần nhờ cache trong _____.