1
Files
flake-nixinator/config/neovim/store/lazy-plugins/mini.nvim/scripts/lintcommit.lua

344 lines
12 KiB
Lua

-- Validate commit message. Designed to be run as pre-commit hook and in CI.
--
-- Each Neovim's argument for when it is opened is assumed to be a path to
-- a file containing commit message to validate
-- Example usage:
-- ```
-- nvim --headless --noplugin -u scripts/lintcommit.lua -- .git/COMMIT_EDITMSG
-- ```
-- Validator functions
local allowed_commit_types = { 'ci', 'docs', 'feat', 'fix', 'refactor', 'style', 'test' }
local allowed_scopes = { 'ALL' }
for _, module in ipairs(vim.fn.readdir('lua/mini')) do
if module ~= 'init.lua' then table.insert(allowed_scopes, module:match('^(.+)%.lua$')) end
end
local validate_subject = function(line)
-- Possibly allow starting with 'fixup' to disable commit linting
if vim.startswith(line, 'fixup') then
local is_strict = vim.loop.os_getenv('LINTCOMMIT_STRICT') ~= nil
local msg = is_strict and 'No "fixup" commits are allowed.' or ''
return not is_strict, msg
end
-- Should match overall conventional commit spec
local commit_type, scope, desc = string.match(line, '^([^(]+)(%b())!?: (.+)$')
if commit_type == nil then
commit_type, desc = string.match(line, '^([^!:]+)!?: (.+)$')
end
if commit_type == nil or desc == nil then
return false,
'First line does not match conventional commit specification of `<type>[optional scope][!]: <description>`: '
.. vim.inspect(line)
end
-- Commit type should be present and be from one of allowed
if not vim.tbl_contains(allowed_commit_types, commit_type) then
local one_of = table.concat(vim.tbl_map(vim.inspect, allowed_commit_types), ', ')
return false, 'Commit type ' .. vim.inspect(commit_type) .. ' is not allowed. Use one of ' .. one_of .. '.'
end
-- Scope, if present, should be from one of allowed
if scope ~= nil then
scope = scope:sub(2, -2)
if not vim.tbl_contains(allowed_scopes, scope) then
local one_of = table.concat(vim.tbl_map(vim.inspect, allowed_scopes), ', ')
return false, 'Scope ' .. vim.inspect(scope) .. ' is not allowed. Use one of ' .. one_of .. '.'
end
end
-- Description should be present and properly formatted
if string.find(desc, '^%w') == nil then
return false, 'Description should start with alphanumeric character: ' .. vim.inspect(desc)
end
if string.find(desc, '^%u%l') ~= nil then
return false, 'Description should not start with capitalized word: ' .. vim.inspect(desc)
end
if string.find(desc, '[.,?!;]$') ~= nil then
return false, 'Description should not end with any of `.,?!;`: ' .. vim.inspect(desc)
end
-- Subject should not be too long
if vim.fn.strdisplaywidth(line) > 72 then
return false, 'First line is longer than 72 characters: ' .. vim.inspect(desc)
end
return true, nil
end
local validate_body = function(parts)
if #parts == 1 then return true, nil end
if parts[2] ~= '' then return false, 'Second line should be empty' end
if parts[3] == nil then return false, 'If first line is not enough, body should be present' end
if string.find(parts[3], '^%S') == nil then return false, 'First body line should not start with whitespace.' end
for i = 3, #parts do
if vim.fn.strdisplaywidth(parts[i]) > 80 then
return false, 'Body line is longer than 80 characters: ' .. vim.inspect(parts[i])
end
end
if string.find(parts[#parts], '^%s*$') ~= nil then return false, 'Body should not end with blank line.' end
return true, nil
end
local validate_bad_wording = function(msg)
local has_fix = msg:find('[Ff]ix #') or msg:find('[Ff]ixes #') or msg:find('[Ff]ixed #')
local has_bad_close = msg:find('[Cc]lose[sd]? #') ~= nil
local has_bad_resolve = msg:find('[Rr]esolve[sd] #') ~= nil
if has_fix or has_bad_close or has_bad_resolve then
return false,
'Use "Resolve #" GitHub keyword to resolve issue/PR '
.. '(not "Fix(/es/ed)", not "Close(/s/d)", not "Resolve(s/d)").'
end
return true, nil
end
local remove_cleanup_lines = function(lines)
-- Remove lines which are assumed to be later cleaned up by Git itself
-- See `git help commit` for option `--cleanup` (assumes default value)
local res = {}
for _, l in ipairs(lines) do
-- Ignore anything past and including scissors line
if l == '# ------------------------ >8 ------------------------' then break end
-- Ignore comments
if l:find('^%s*#') == nil then table.insert(res, l) end
end
-- Ignore trailing blank lines
for i = #res, 1, -1 do
if res[i]:find('%S') ~= nil then break end
res[i] = nil
end
return res
end
local validate_commit_msg = function(lines)
local is_valid, err_msg
-- If not in strict context, ignore lines which will be later cleaned up
local is_strict = vim.loop.os_getenv('LINTCOMMIT_STRICT') ~= nil
if not is_strict then lines = remove_cleanup_lines(lines) end
-- Allow all lines to be empty to abort committing
local all_empty = true
for _, l in ipairs(lines) do
if l ~= '' then all_empty = false end
end
if all_empty then return true, nil end
-- Validate subject (first line)
is_valid, err_msg = validate_subject(lines[1])
if not is_valid then return is_valid, err_msg end
-- Validate body
is_valid, err_msg = validate_body(lines)
if not is_valid then return is_valid, err_msg end
-- No validation for footer
-- Should not contain bad wording
for _, l in ipairs(lines) do
is_valid, err_msg = validate_bad_wording(l)
if not is_valid then return is_valid, err_msg end
end
return true, nil
end
local validate_commit_msg_from_file = function(path)
local ok, lines = pcall(vim.fn.readfile, path)
if not ok then return false, 'Could not read file ' .. path end
return validate_commit_msg(lines)
end
-- Actual validation
local exit_code = 0
for i = 0, vim.fn.argc(-1) - 1 do
local path = vim.fn.argv(i, -1)
io.write('Commit message of ' .. vim.fn.fnamemodify(path, ':t') .. ':\n')
local is_valid, err_msg = validate_commit_msg_from_file(path)
io.write((is_valid and 'OK' or err_msg) .. '\n\n')
if not is_valid then exit_code = 1 end
end
os.exit(exit_code)
-- Tests to be run interactively: `_G.test_cases_failed` should be empty.
-- NOTE: Comment out previous `os.exit()` call
local test_cases = {
-- Subject
['fixup'] = true,
['fixup: commit message'] = true,
['fixup! commit message'] = true,
['ci: normal message'] = true,
['docs: normal message'] = true,
['feat: normal message'] = true,
['fix: normal message'] = true,
['refactor: normal message'] = true,
['style: normal message'] = true,
['test: normal message'] = true,
['feat(ai): message with scope'] = true,
['feat!: message with breaking change'] = true,
['feat(ai)!: message with scope and breaking change'] = true,
['style(ALL): style all modules'] = true,
['unknown: unknown type'] = false,
['feat(unknown): unknown scope'] = false,
['refactor(): empty scope'] = false,
['ci( ): whitespace as scope'] = false,
['ci no colon after type'] = false,
[': no type before colon 1'] = false,
[' : no type before colon 2'] = false,
[' : no type before colon 3'] = false,
['ci:'] = false,
['ci: '] = false,
['ci: '] = false,
['feat: message with : in it'] = true,
['feat(ai): message with : in it'] = true,
['test: extra space after colon'] = false,
['ci: tab after colon'] = false,
['ci:no space after colon'] = false,
['ci : extra space before colon'] = false,
['ci: bad punctuation at end of sentence.'] = false,
['ci: bad punctuation at end of sentence,'] = false,
['ci: bad punctuation at end of sentence?'] = false,
['ci: bad punctuation at end of sentence!'] = false,
['ci: bad punctuation at end of sentence;'] = false,
['ci: good punctuation at end of sentence:'] = true,
['ci: good punctuation at end of sentence"'] = true,
["ci: good punctuation at end of sentence'"] = true,
['ci: good punctuation at end of sentence)'] = true,
['ci: good punctuation at end of sentence]'] = true,
['ci: Capitalized first word'] = false,
['ci: UPPER_CASE First Word'] = true,
['ci: very very very very very very very very very very very looong subject'] = false,
-- Body
['ci: desc\n\nBody'] = true,
['ci: desc\n\nBody\n\nwith\n \nempty and blank lines'] = true,
['ci: desc\nSecond line is not empty'] = false,
['ci: desc\n\n First body line starts with whitespace'] = false,
-- Line width should be checked only in not cleaned up lines
['ci: desc\n\nBody\nwith\nVery very very very very very very very very very very very very looong body line'] = false,
['ci: desc\n\nBody\nwith\n# Comment with very very very very very very very very very very looong body line'] = true,
['ci: desc\n\nBody\nwith\n# ------------------------ >8 ------------------------\nVery very very very very very very very very very very very very looong body line'] = true,
-- Trailing blank lines are allowed in not strict context
['ci: only two lines\n\n'] = true,
['ci: desc\n\nLast line is empty\n\n'] = true,
['ci: desc\n\nLast line is blank\n '] = true,
-- Footer
-- No validation for footer
-- Bad wordings
['ci: this has Fixed #1'] = false,
['ci: this has fixed #1'] = false,
['ci: this Fixes #1'] = false,
['ci: this fixes #1'] = false,
['ci: this will Fix #1'] = false,
['ci: this will fix #1'] = false,
['ci: this has Closed #1'] = false,
['ci: this has closed #1'] = false,
['ci: this Closes #1'] = false,
['ci: this closes #1'] = false,
['ci: this will Close #1'] = false,
['ci: this will close #1'] = false,
['ci: this has Resolved #1'] = false,
['ci: this has resolved #1'] = false,
['ci: this Resolves #1'] = false,
['ci: this resolves #1'] = false,
['ci: desc\n\nthis has Fixed #1'] = false,
['ci: desc\n\nthis has fixed #1'] = false,
['ci: desc\n\nthis Fixes #1'] = false,
['ci: desc\n\nthis fixes #1'] = false,
['ci: desc\n\nthis will Fix #1'] = false,
['ci: desc\n\nthis will fix #1'] = false,
['ci: desc\n\nthis has Closed #1'] = false,
['ci: desc\n\nthis has closed #1'] = false,
['ci: desc\n\nthis Closes #1'] = false,
['ci: desc\n\nthis closes #1'] = false,
['ci: desc\n\nthis will Close #1'] = false,
['ci: desc\n\nthis will close #1'] = false,
['ci: desc\n\nthis has Resolved #1'] = false,
['ci: desc\n\nthis has resolved #1'] = false,
['ci: desc\n\nthis Resolves #1'] = false,
['ci: desc\n\nthis resolves #1'] = false,
-- Comments are allowed in not strict context
['# Comment\nci: desc'] = true,
[' # Comment\nci: desc'] = true,
['ci: desc\n# Comment\n\nBody'] = true,
-- Allow all empty lines
[''] = true,
['\n'] = true,
['\n# Comment'] = true,
}
_G.test_cases_failed = {}
vim.loop.os_unsetenv('LINTCOMMIT_STRICT')
for message, expected in pairs(test_cases) do
local lines = vim.split(message, '\n')
local is_valid = validate_commit_msg(lines)
if is_valid ~= expected then
table.insert(_G.test_cases_failed, { msg = message, expected = expected, actual = is_valid })
end
end
vim.loop.os_setenv('LINTCOMMIT_STRICT', 'true')
local strict_test_cases = {
-- Fixup commit type is not allowed
['fixup'] = false,
['fixup: should fail'] = false,
['fixup! should fail'] = false,
-- - Should only matter in subject
['ci: desc\n\nfixup'] = true,
-- Do not allow comments outside of commit body
['# Comment\nci: desc'] = false,
[' # Comment\nci: desc'] = false,
['ci: desc\n# Comment\n\nBody'] = false,
['ci: desc\n\nBody\n# Comment in body'] = true,
-- Check line width even in previously ignored contexts
['ci: desc\n\nBody\nwith\n# Comment with very very very very very very very very very very looong body line'] = false,
['ci: desc\n\nBody\nwith\n# ------------------------ >8 ------------------------\nVery very very very very very very very very very very very very looong body line'] = false,
-- Trailing blank lines are not allowed in strict context
['ci: only two lines\n\n'] = false,
['ci: desc\n\nLast line is empty\n\n'] = false,
['ci: desc\n\nLast line is blank\n '] = false,
}
for message, expected in pairs(strict_test_cases) do
local lines = vim.split(message, '\n')
local is_valid = validate_commit_msg(lines)
if is_valid ~= expected then
table.insert(_G.test_cases_failed, { msg = message, expected = expected, actual = is_valid })
end
end
-- Cleanup
vim.loop.os_unsetenv('LINTCOMMIT_STRICT')