diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd734c45..beb619c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,21 @@ jobs: run: cargo install stylua --locked --version 2.4.1 --features luajit - name: Check formatting - run: stylua --check lua/ + run: stylua --check lua/ scripts/ + + config-schema: + runs-on: ubuntu-latest + name: Config schema drift check + steps: + - uses: actions/checkout@v4 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + + - name: Check DiffviewConfig sync with M.defaults + run: make check-config-schema test: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c6fddc3..e7aed018 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,8 +32,8 @@ cargo install stylua --locked --version 2.4.1 --features luajit Then run: ```bash -stylua --check lua/ # CI equivalent; fails on any diff -stylua lua/ # apply formatting +stylua --check lua/ scripts/ # CI equivalent; fails on any diff +stylua lua/ scripts/ # apply formatting ``` ## Adding or changing a config option @@ -54,6 +54,10 @@ When you add, remove, or rename a key under `M.defaults` in 5. `doc/diffview.txt` — the reference section for that option, if it warrants prose (type, valid values, behavioural notes). +The CI job `config-schema` performs a mechanical key-drift/consistency check +to ensure #1, #2, and #3 stay in sync. Doc/reference drift (#4 and #5) is +reviewer-enforced. + ## Commit messages Use [Conventional Commits](https://www.conventionalcommits.org/). Keep the diff --git a/Makefile b/Makefile index 685567d4..26f6142d 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ test: nvim --headless -i NONE -n -u scripts/test_init.lua -c \ "PlenaryBustedDirectory $(TEST_PATH) { minimal_init = './scripts/test_init.lua' }" +.PHONY: check-config-schema +check-config-schema: + nvim --headless -i NONE -n -u NONE -c "luafile scripts/check_config_schema.lua" + .PHONY: dev dev: .dev/lua/nvim diff --git a/scripts/check_config_schema.lua b/scripts/check_config_schema.lua new file mode 100644 index 00000000..d505b8f1 --- /dev/null +++ b/scripts/check_config_schema.lua @@ -0,0 +1,288 @@ +-- Verify `M.defaults`, `@class DiffviewConfig`, and `@class DiffviewConfig.user` +-- declare the same set of top-level keys. +-- +-- The check is purely source-based so that keys explicitly assigned `nil` in +-- `M.defaults` (e.g. `preferred_adapter`, `rename_threshold`) are still +-- counted; runtime `pairs()` would skip them. +-- +-- Checks (top level only): +-- 1. every key in `M.defaults` has a `@field` entry on both classes; +-- 2. every `@field` entry on either class has a key in `M.defaults`; +-- 3. `DiffviewConfig` and `DiffviewConfig.user` declare the same set of keys. +-- +-- Run with: +-- nvim --headless -i NONE -n -u NONE -c "luafile scripts/check_config_schema.lua" + +local function repo_root() + local f = debug.getinfo(1, "S").source:sub(2) + return vim.fn.fnamemodify(f, ":p:h:h") +end + +local function read_file(path) + local file = assert(io.open(path)) + local content = file:read("*a") + file:close() + return content +end + +local root = repo_root() +local src = read_file(root .. "/lua/diffview/config.lua") + +---Match a Lua long-bracket opening (`[[`, `[=[`, `[==[`, ...) at `p`. +---Returns `(level, body_start)` where `level` is the number of `=` signs and +---`body_start` is just past the final `[`, or nil if no long bracket opens +---at `p`. +---@param p integer +---@return integer? level +---@return integer? body_start +local function long_open(p) + if src:sub(p, p) ~= "[" then + return nil + end + local q = p + 1 + while src:sub(q, q) == "=" do + q = q + 1 + end + if src:sub(q, q) == "[" then + return q - p - 1, q + 1 + end + return nil +end + +---Find the matching long-bracket close (`]]`, `]=]`, ...) for the given level, +---starting at `p`. Returns the position just past the close, or nil. +---@param p integer +---@param level integer +---@return integer? +local function long_close(p, level) + local needle = "]" .. string.rep("=", level) .. "]" + local _, e = src:find(needle, p, true) + return e and (e + 1) or nil +end + +---Extract top-level keys from the `M.defaults = { ... }` source block, +---including keys explicitly assigned `nil` (which `pairs()` would skip). +---@return table? +---@return string? err +local function extract_defaults_keys() + local _, eq_end = src:find("M%.defaults%s*=%s*") + if not eq_end then + return nil, "could not locate M.defaults assignment" + end + local open = src:find("{", eq_end + 1) + if not open then + return nil, "M.defaults: opening brace not found" + end + + local keys = {} + local depth = 1 + local pos = open + 1 + local len = #src + local in_string = nil + + while pos <= len do + local c = src:sub(pos, pos) + if in_string then + if c == "\\" then + pos = pos + 2 + elseif c == in_string then + in_string = nil + pos = pos + 1 + else + pos = pos + 1 + end + elseif c == "-" and src:sub(pos + 1, pos + 1) == "-" then + -- Comment. Either a `--[[ ... ]]` block or a line comment to EOL. + local level, body_start = long_open(pos + 2) + if level then + local after = long_close(body_start, level) + if not after then + return nil, "M.defaults: unterminated block comment" + end + pos = after + else + local nl = src:find("\n", pos) + pos = nl and (nl + 1) or (len + 1) + end + elseif c == '"' or c == "'" then + in_string = c + pos = pos + 1 + elseif c == "[" then + -- Possible long-bracket string (`[[...]]`, `[=[...]=]`, ...); short + -- brackets like `["A"]` fall through to the single-char advance. + local level, body_start = long_open(pos) + if level then + local after = long_close(body_start, level) + if not after then + return nil, "M.defaults: unterminated long string" + end + pos = after + else + pos = pos + 1 + end + elseif c == "{" then + depth = depth + 1 + pos = pos + 1 + elseif c == "}" then + depth = depth - 1 + if depth == 0 then + break + end + pos = pos + 1 + elseif depth == 1 and c:match("[%a_]") then + local _, e, name = src:find("^([%w_]+)%s*=", pos) + if name then + keys[name] = true + pos = e + 1 + else + pos = pos + 1 + end + else + pos = pos + 1 + end + end + + if depth ~= 0 then + return nil, "M.defaults: unbalanced braces" + end + + return keys +end + +---Extract top-level `@field` names from a `---@class ` block. The block +---runs from the `@class` header until the next `---@class` declaration or the +---first non-comment line. Blank lines within the block are tolerated so that +---fields grouped with blank lines for readability are still picked up. +---@param class_name string +---@return table? +---@return string? err +local function extract_fields(class_name) + local escaped = class_name:gsub("%.", "%%.") + -- `%f[%s]` ensures the class name is a whole word, so `DiffviewConfig` + -- doesn't accidentally also match `DiffviewConfig.user`. + local pattern = "%-%-%-@class%s+" .. escaped .. "%f[%s][^\n]*\n" + local _, header_end = src:find(pattern) + if not header_end then + return nil, "could not locate @class block: " .. class_name + end + + local fields = {} + local pos = header_end + 1 + local len = #src + while pos <= len do + local nl = src:find("\n", pos) or (len + 1) + local line = src:sub(pos, nl - 1) + if line:match("^%-%-%-@class") then + break + elseif line:match("^%s*$") then + -- Blank line; keep scanning in case fields continue below. + pos = nl + 1 + elseif not line:match("^%s*%-%-") then + break + else + local name = line:match("^%-%-%-@field%s+([%w_]+)%??") + or line:match('^%-%-%-@field%s+%["([^"]+)"%]%??') + if name then + fields[name] = true + end + pos = nl + 1 + end + end + + return fields +end + +local function die(msg) + io.stderr:write(msg .. "\n") + vim.cmd("cquit 1") +end + +local defaults, err = extract_defaults_keys() +if not defaults then + die(err) +end + +local internal, err2 = extract_fields("DiffviewConfig") +if not internal then + die(err2) +end + +local user, err3 = extract_fields("DiffviewConfig.user") +if not user then + die(err3) +end + +local function sorted_diff(a, b) + local out = {} + for k in pairs(a) do + if not b[k] then + out[#out + 1] = k + end + end + table.sort(out) + return out +end + +local function report(errors, label, diff) + if #diff > 0 then + table.insert(errors, label .. ": " .. table.concat(diff, ", ")) + end +end + +local errors = {} +report( + errors, + "In M.defaults but missing from @class DiffviewConfig", + sorted_diff(defaults, internal) +) +report( + errors, + "In M.defaults but missing from @class DiffviewConfig.user", + sorted_diff(defaults, user) +) +report( + errors, + "Declared on @class DiffviewConfig but missing from M.defaults", + sorted_diff(internal, defaults) +) +report( + errors, + "Declared on @class DiffviewConfig.user but missing from M.defaults", + sorted_diff(user, defaults) +) +report( + errors, + "Declared on DiffviewConfig but not on DiffviewConfig.user", + sorted_diff(internal, user) +) +report( + errors, + "Declared on DiffviewConfig.user but not on DiffviewConfig", + sorted_diff(user, internal) +) + +if #errors > 0 then + io.stderr:write("Config schema drift detected:\n") + for _, e in ipairs(errors) do + io.stderr:write(" " .. e .. "\n") + end + io.stderr:write("\nAdd or remove the corresponding entries (see CONTRIBUTING.md).\n") + vim.cmd("cquit 1") +end + +local function count(t) + local n = 0 + for _ in pairs(t) do + n = n + 1 + end + return n +end + +print( + string.format( + "OK: M.defaults, @class DiffviewConfig, and @class DiffviewConfig.user agree on %d top-level keys.", + count(defaults) + ) +) + +vim.cmd("qa!")