neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

lintcommit.lua (8744B)


      1 -- Usage:
      2 --    # verbose
      3 --    nvim -l scripts/lintcommit.lua main --trace
      4 --
      5 --    # silent
      6 --    nvim -l scripts/lintcommit.lua main
      7 --
      8 --    # self-test
      9 --    nvim -l scripts/lintcommit.lua _test
     10 
     11 --- @type table<string,fun(opt: LintcommitOptions)>
     12 local M = {}
     13 
     14 local _trace = false
     15 
     16 -- Print message
     17 local function p(s)
     18  vim.cmd('set verbose=1')
     19  vim.api.nvim_echo({ { s, '' } }, false, {})
     20  vim.cmd('set verbose=0')
     21 end
     22 
     23 -- Executes and returns the output of `cmd`, or nil on failure.
     24 --
     25 -- Prints `cmd` if `trace` is enabled.
     26 local function run(cmd, or_die)
     27  if _trace then
     28    p('run: ' .. vim.inspect(cmd))
     29  end
     30  local res = vim.system(cmd):wait()
     31  local rv = vim.trim(res.stdout)
     32  if res.code ~= 0 then
     33    if or_die then
     34      p(rv)
     35      os.exit(1)
     36    end
     37    return nil
     38  end
     39  return rv
     40 end
     41 
     42 -- Returns nil if the given commit message is valid, or returns a string
     43 -- message explaining why it is invalid.
     44 local function validate_commit(commit_message)
     45  local commit_split = vim.split(commit_message, ':', { plain = true })
     46  -- Return nil if the type is vim-patch since most of the normal rules don't
     47  -- apply.
     48  if commit_split[1] == 'vim-patch' then
     49    return nil
     50  end
     51 
     52  -- Check that message isn't too long.
     53  if commit_message:len() > 80 then
     54    return [[Commit message is too long, a maximum of 80 characters is allowed.]]
     55  end
     56 
     57  local before_colon = commit_split[1]
     58 
     59  local after_idx = 2
     60  if before_colon:match('^[^%(]*%([^%)]*$') then
     61    -- Need to find the end of commit scope when commit scope contains colons.
     62    while after_idx <= vim.tbl_count(commit_split) do
     63      after_idx = after_idx + 1
     64      if commit_split[after_idx - 1]:find(')') then
     65        break
     66      end
     67    end
     68  end
     69  if after_idx > vim.tbl_count(commit_split) then
     70    return [[Commit message does not include colons.]]
     71  end
     72  local after_colon_split = {}
     73  while after_idx <= vim.tbl_count(commit_split) do
     74    table.insert(after_colon_split, commit_split[after_idx])
     75    after_idx = after_idx + 1
     76  end
     77  local after_colon = table.concat(after_colon_split, ':')
     78 
     79  -- Check if commit introduces a breaking change.
     80  if vim.endswith(before_colon, '!') then
     81    before_colon = before_colon:sub(1, -2)
     82  end
     83 
     84  -- Check if type is correct
     85  local type = vim.split(before_colon, '(', { plain = true })[1]
     86  local allowed_types =
     87    { 'build', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'test', 'vim-patch' }
     88  if not vim.tbl_contains(allowed_types, type) then
     89    return string.format(
     90      [[Invalid commit type "%s". Allowed types are:
     91      %s.
     92    If none of these seem appropriate then use "fix"]],
     93      type,
     94      vim.inspect(allowed_types)
     95    )
     96  end
     97 
     98  -- Check if scope is appropriate
     99  if before_colon:match('%(') then
    100    local scope = vim.trim(commit_message:match('%((.-)%)'))
    101 
    102    if scope == '' then
    103      return [[Scope can't be empty]]
    104    end
    105 
    106    if vim.startswith(scope, 'nvim_') then
    107      return [[Scope should be "api" instead of "nvim_..."]]
    108    end
    109 
    110    local alternative_scope = {
    111      ['filetype.vim'] = 'filetype',
    112      ['filetype.lua'] = 'filetype',
    113      ['tree-sitter'] = 'treesitter',
    114      ['ts'] = 'treesitter',
    115      ['hl'] = 'highlight',
    116    }
    117 
    118    if alternative_scope[scope] then
    119      return ('Scope should be "%s" instead of "%s"'):format(alternative_scope[scope], scope)
    120    end
    121  end
    122 
    123  -- Check that description doesn't end with a period
    124  if vim.endswith(after_colon, '.') then
    125    return [[Description ends with a period (".").]]
    126  end
    127 
    128  -- Check that description starts with a whitespace.
    129  if after_colon:sub(1, 1) ~= ' ' then
    130    return [[There should be a whitespace after the colon.]]
    131  end
    132 
    133  -- Check that description doesn't start with multiple whitespaces.
    134  if after_colon:sub(1, 2) == '  ' then
    135    return [[There should only be one whitespace after the colon.]]
    136  end
    137 
    138  -- Allow lowercase or ALL_UPPER but not Titlecase.
    139  if after_colon:match('^ *%u%l') then
    140    return [[Description first word should not be Capitalized.]]
    141  end
    142 
    143  -- Check that description isn't just whitespaces
    144  if vim.trim(after_colon) == '' then
    145    return [[Description shouldn't be empty.]]
    146  end
    147 
    148  return nil
    149 end
    150 
    151 --- @param opt? LintcommitOptions
    152 function M.main(opt)
    153  _trace = not opt or not not opt.trace
    154 
    155  local branch = run({ 'git', 'rev-parse', '--abbrev-ref', 'HEAD' }, true)
    156  -- TODO(justinmk): check $GITHUB_REF
    157  local ancestor = run({ 'git', 'merge-base', 'origin/master', branch })
    158  if not ancestor then
    159    ancestor = run({ 'git', 'merge-base', 'upstream/master', branch })
    160  end
    161  local commits_str = run({ 'git', 'rev-list', ancestor .. '..' .. branch }, true)
    162  assert(commits_str)
    163 
    164  local commits = {} --- @type string[]
    165  for substring in commits_str:gmatch('%S+') do
    166    table.insert(commits, substring)
    167  end
    168 
    169  local failed = 0
    170  for _, commit_id in ipairs(commits) do
    171    local msg = run({ 'git', 'show', '-s', '--format=%s', commit_id })
    172    if vim.v.shell_error ~= 0 then
    173      p('Invalid commit-id: ' .. commit_id .. '"')
    174    else
    175      local invalid_msg = validate_commit(msg)
    176      if invalid_msg then
    177        failed = failed + 1
    178 
    179        -- Some breathing room
    180        if failed == 1 then
    181          p('\n')
    182        end
    183 
    184        p(string.format(
    185          [[
    186 Invalid commit message: "%s"
    187    Commit: %s
    188    %s
    189 ]],
    190          msg,
    191          commit_id,
    192          invalid_msg
    193        ))
    194      end
    195    end
    196  end
    197 
    198  if failed > 0 then
    199    p([[
    200 See also:
    201    https://github.com/neovim/neovim/blob/master/CONTRIBUTING.md#commit-messages
    202 
    203 ]])
    204    os.exit(1)
    205  else
    206    p('')
    207  end
    208 end
    209 
    210 function M._test()
    211  -- message:expected_result
    212  local test_cases = {
    213    ['ci: normal message'] = true,
    214    ['build: normal message'] = true,
    215    ['docs: normal message'] = true,
    216    ['feat: normal message'] = true,
    217    ['fix: normal message'] = true,
    218    ['perf: normal message'] = true,
    219    ['refactor: normal message'] = true,
    220    ['revert: normal message'] = true,
    221    ['test: normal message'] = true,
    222    ['ci(window): message with scope'] = true,
    223    ['ci!: message with breaking change'] = true,
    224    ['ci(tui)!: message with scope and breaking change'] = true,
    225    ['vim-patch:8.2.3374: Pyret files are not recognized (#15642)'] = true,
    226    ['vim-patch:8.1.1195,8.2.{3417,3419}'] = true,
    227    ['revert: "ci: use continue-on-error instead of "|| true""'] = true,
    228    ['fixup'] = false,
    229    ['fixup: commit message'] = false,
    230    ['fixup! commit message'] = false,
    231    [':no type before colon 1'] = false,
    232    [' :no type before colon 2'] = false,
    233    ['  :no type before colon 3'] = false,
    234    ['ci(empty description):'] = false,
    235    ['ci(only whitespace as description): '] = false,
    236    ['docs(multiple whitespaces as description):   '] = false,
    237    ['revert(multiple whitespaces and then characters as description):  description'] = false,
    238    ['ci no colon after type'] = false,
    239    ['test:  extra space after colon'] = false,
    240    ['ci:	tab after colon'] = false,
    241    ['ci:no space after colon'] = false,
    242    ['ci :extra space before colon'] = false,
    243    ['refactor(): empty scope'] = false,
    244    ['ci( ): whitespace as scope'] = false,
    245    ['ci: period at end of sentence.'] = false,
    246    ['ci: period: at end of sentence.'] = false,
    247    ['ci: Capitalized first word'] = false,
    248    ['ci: UPPER_CASE First Word'] = true,
    249    ['unknown: using unknown type'] = false,
    250    ['feat: foo:bar'] = true,
    251    ['feat: :foo:bar'] = true,
    252    ['feat: :Foo:Bar'] = true,
    253    ['feat(something): foo:bar'] = true,
    254    ['feat(something): :foo:bar'] = true,
    255    ['feat(something): :Foo:Bar'] = true,
    256    ['feat(:grep): read from pipe'] = true,
    257    ['feat(:grep/:make): read from pipe'] = true,
    258    ['feat(:grep): foo:bar'] = true,
    259    ['feat(:grep/:make): foo:bar'] = true,
    260    ['feat(:grep)'] = false,
    261    ['feat(:grep/:make)'] = false,
    262    ['feat(:grep'] = false,
    263    ['feat(:grep/:make'] = false,
    264    ["ci: you're saying this commit message just goes on and on and on and on and on and on for way too long?"] = false,
    265  }
    266 
    267  local failed = 0
    268  for message, expected in pairs(test_cases) do
    269    local is_valid = (nil == validate_commit(message))
    270    if is_valid ~= expected then
    271      failed = failed + 1
    272      p(
    273        string.format('[ FAIL ]: expected=%s, got=%s\n    input: "%s"', expected, is_valid, message)
    274      )
    275    end
    276  end
    277 
    278  if failed > 0 then
    279    os.exit(1)
    280  end
    281 end
    282 
    283 --- @class LintcommitOptions
    284 --- @field trace? boolean
    285 local opt = {}
    286 
    287 for _, a in ipairs(arg) do
    288  if vim.startswith(a, '--') then
    289    local nm, val = a:sub(3), true
    290    if vim.startswith(a, '--no') then
    291      nm, val = a:sub(5), false
    292    end
    293 
    294    if nm == 'trace' then
    295      opt.trace = val
    296    end
    297  end
    298 end
    299 
    300 for _, a in ipairs(arg) do
    301  if M[a] then
    302    M[a](opt)
    303  end
    304 end