neovim

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

clint.lua (46955B)


      1 #!/usr/bin/env nvim -l
      2 
      3 -- Lints C files in the Neovim source tree.
      4 -- Based on Google "cpplint", modified for Neovim.
      5 --
      6 -- Test coverage: `test/functional/script/clint_spec.lua`
      7 --
      8 -- This can get very confused by /* and // inside strings! We do a small hack,
      9 -- which is to ignore //'s with "'s after them on the same line, but it is far
     10 -- from perfect (in either direction).
     11 
     12 local vim = vim
     13 
     14 -- Error categories used for filtering
     15 local ERROR_CATEGORIES = {
     16  'build/endif_comment',
     17  'build/header_guard',
     18  'build/include_defs',
     19  'build/defs_header',
     20  'build/printf_format',
     21  'build/storage_class',
     22  'build/init_macro',
     23  'readability/bool',
     24  'readability/multiline_comment',
     25  -- Dropped 'readability/multiline_string' detection because it is too buggy, and uncommon.
     26  -- 'readability/multiline_string',
     27  'readability/nul',
     28  'readability/utf8',
     29  'readability/increment',
     30  'runtime/arrays',
     31  'runtime/int',
     32  'runtime/memset',
     33  'runtime/printf',
     34  'runtime/printf_format',
     35  'runtime/threadsafe_fn',
     36  'runtime/deprecated',
     37  'whitespace/indent',
     38  'whitespace/operators',
     39  'whitespace/cast',
     40 }
     41 
     42 -- Default filters (empty by default)
     43 local DEFAULT_FILTERS = {}
     44 
     45 -- Assembly state constants
     46 local NO_ASM = 0 -- Outside of inline assembly block
     47 local INSIDE_ASM = 1 -- Inside inline assembly block
     48 local END_ASM = 2 -- Last line of inline assembly block
     49 local BLOCK_ASM = 3 -- The whole block is an inline assembly block
     50 
     51 -- Regex compilation cache
     52 local regexp_compile_cache = {}
     53 
     54 -- Error suppression state
     55 local error_suppressions = {}
     56 local error_suppressions_2 = {}
     57 
     58 -- Configuration
     59 local valid_extensions = { c = true, h = true }
     60 
     61 -- Precompiled regex patterns (only the ones still used)
     62 local RE_SUPPRESSION = vim.regex([[\<NOLINT\>]])
     63 local RE_COMMENTLINE = vim.regex([[^\s*//]])
     64 local RE_PATTERN_INCLUDE = vim.regex([[^\s*#\s*include\s*\([<"]\)\([^>"]*\)[>"].*$]])
     65 
     66 -- Assembly block matching (using Lua pattern instead of vim.regex for simplicity)
     67 local function match_asm(line)
     68  return line:find('^%s*asm%s*[{(]')
     69    or line:find('^%s*_asm%s*[{(]')
     70    or line:find('^%s*__asm%s*[{(]')
     71    or line:find('^%s*__asm__%s*[{(]')
     72 end
     73 
     74 -- Threading function replacements
     75 local threading_list = {
     76  { 'asctime(', 'os_asctime_r(' },
     77  { 'ctime(', 'os_ctime_r(' },
     78  { 'getgrgid(', 'os_getgrgid_r(' },
     79  { 'getgrnam(', 'os_getgrnam_r(' },
     80  { 'getlogin(', 'os_getlogin_r(' },
     81  { 'getpwnam(', 'os_getpwnam_r(' },
     82  { 'getpwuid(', 'os_getpwuid_r(' },
     83  { 'gmtime(', 'os_gmtime_r(' },
     84  { 'localtime(', 'os_localtime_r(' },
     85  { 'strtok(', 'os_strtok_r(' },
     86  { 'ttyname(', 'os_ttyname_r(' },
     87  { 'asctime_r(', 'os_asctime_r(' },
     88  { 'ctime_r(', 'os_ctime_r(' },
     89  { 'getgrgid_r(', 'os_getgrgid_r(' },
     90  { 'getgrnam_r(', 'os_getgrnam_r(' },
     91  { 'getlogin_r(', 'os_getlogin_r(' },
     92  { 'getpwnam_r(', 'os_getpwnam_r(' },
     93  { 'getpwuid_r(', 'os_getpwuid_r(' },
     94  { 'gmtime_r(', 'os_gmtime_r(' },
     95  { 'localtime_r(', 'os_localtime_r(' },
     96  { 'strtok_r(', 'os_strtok_r(' },
     97  { 'ttyname_r(', 'os_ttyname_r(' },
     98 }
     99 
    100 -- Memory function replacements
    101 local memory_functions = {
    102  { 'malloc(', 'xmalloc(' },
    103  { 'calloc(', 'xcalloc(' },
    104  { 'realloc(', 'xrealloc(' },
    105  { 'strdup(', 'xstrdup(' },
    106  { 'free(', 'xfree(' },
    107 }
    108 local memory_ignore_pattern = vim.regex([[src/nvim/memory.c$]])
    109 
    110 -- OS function replacements
    111 local os_functions = {
    112  { 'setenv(', 'os_setenv(' },
    113  { 'getenv(', 'os_getenv(' },
    114  { '_wputenv(', 'os_setenv(' },
    115  { '_putenv_s(', 'os_setenv(' },
    116  { 'putenv(', 'os_setenv(' },
    117  { 'unsetenv(', 'os_unsetenv(' },
    118 }
    119 
    120 -- CppLintState class equivalent
    121 local CppLintState = {}
    122 CppLintState.__index = CppLintState
    123 
    124 function CppLintState.new()
    125  local self = setmetatable({}, CppLintState)
    126  self.verbose_level = 1
    127  self.error_count = 0
    128  self.filters = vim.deepcopy(DEFAULT_FILTERS)
    129  self.counting = 'total'
    130  self.errors_by_category = {}
    131  self.stdin_filename = ''
    132  self.output_format = 'emacs'
    133  self.record_errors_file = nil
    134  self.suppressed_errors = vim.defaulttable(function()
    135    return vim.defaulttable(function()
    136      return {}
    137    end)
    138  end)
    139  return self
    140 end
    141 
    142 function CppLintState:set_output_format(output_format)
    143  self.output_format = output_format
    144 end
    145 
    146 function CppLintState:set_verbose_level(level)
    147  local last_verbose_level = self.verbose_level
    148  self.verbose_level = level
    149  return last_verbose_level
    150 end
    151 
    152 function CppLintState:set_counting_style(counting_style)
    153  self.counting = counting_style
    154 end
    155 
    156 function CppLintState:set_filters(filters)
    157  self.filters = vim.deepcopy(DEFAULT_FILTERS)
    158  for filt in vim.gsplit(filters, ',', { trimempty = true }) do
    159    local clean_filt = vim.trim(filt)
    160    if clean_filt ~= '' then
    161      table.insert(self.filters, clean_filt)
    162    end
    163  end
    164 
    165  for _, filt in ipairs(self.filters) do
    166    if not (filt:sub(1, 1) == '+' or filt:sub(1, 1) == '-') then
    167      error('Every filter in --filters must start with + or - (' .. filt .. ' does not)')
    168    end
    169  end
    170 end
    171 
    172 function CppLintState:reset_error_counts()
    173  self.error_count = 0
    174  self.errors_by_category = {}
    175 end
    176 
    177 function CppLintState:increment_error_count(category)
    178  self.error_count = self.error_count + 1
    179  if self.counting == 'toplevel' or self.counting == 'detailed' then
    180    local cat = category
    181    if self.counting ~= 'detailed' then
    182      cat = category:match('([^/]+)') or category
    183    end
    184    if not self.errors_by_category[cat] then
    185      self.errors_by_category[cat] = 0
    186    end
    187    self.errors_by_category[cat] = self.errors_by_category[cat] + 1
    188  end
    189 end
    190 
    191 function CppLintState:print_error_counts()
    192  for category, count in pairs(self.errors_by_category) do
    193    io.write(string.format("Category '%s' errors found: %d\n", category, count))
    194  end
    195  if self.error_count > 0 then
    196    io.write(string.format('Total errors found: %d\n', self.error_count))
    197  end
    198 end
    199 
    200 function CppLintState:suppress_errors_from(fname)
    201  if not fname then
    202    return
    203  end
    204 
    205  local ok, content = pcall(vim.fn.readfile, fname)
    206  if not ok then
    207    return
    208  end
    209 
    210  for _, line in ipairs(content) do
    211    local ok2, data = pcall(vim.json.decode, line)
    212    if ok2 then
    213      local fname2, lines, category = data[1], data[2], data[3]
    214      local lines_tuple = vim.tbl_islist(lines) and lines or { lines }
    215      self.suppressed_errors[fname2][vim.inspect(lines_tuple)][category] = true
    216    end
    217  end
    218 end
    219 
    220 function CppLintState:record_errors_to(fname)
    221  if not fname then
    222    return
    223  end
    224  self.record_errors_file = io.open(fname, 'w')
    225 end
    226 
    227 -- Global state instance
    228 local cpplint_state = CppLintState.new()
    229 
    230 -- Utility functions
    231 local function match(pattern, s)
    232  if not regexp_compile_cache[pattern] then
    233    regexp_compile_cache[pattern] = vim.regex(pattern)
    234  end
    235  local s_idx, e_idx = regexp_compile_cache[pattern]:match_str(s)
    236  if s_idx then
    237    local match_obj = {}
    238    match_obj.start = s_idx
    239    match_obj.finish = e_idx
    240    function match_obj.group(n)
    241      if n == 0 then
    242        return s:sub(s_idx + 1, e_idx)
    243      else
    244        -- For subgroups, we need to use a different approach
    245        -- This is a simplified version - full regex groups would need more complex handling
    246        return s:sub(s_idx + 1, e_idx)
    247      end
    248    end
    249    return match_obj
    250  end
    251  return nil
    252 end
    253 
    254 -- NOLINT suppression functions
    255 local function parse_nolint_suppressions(raw_line, linenum)
    256  local s_idx, e_idx = RE_SUPPRESSION:match_str(raw_line)
    257  if not s_idx then
    258    return
    259  end
    260 
    261  -- Extract what comes after NOLINT, looking for optional (category)
    262  local after_nolint = raw_line:sub(e_idx + 1)
    263  local category = after_nolint:match('^%s*(%([^)]*%))')
    264 
    265  if not category or category == '(*)' then
    266    -- Suppress all errors on this line
    267    if not error_suppressions[vim.NIL] then
    268      error_suppressions[vim.NIL] = {}
    269    end
    270    table.insert(error_suppressions[vim.NIL], linenum)
    271  else
    272    -- Extract category name from parentheses
    273    local cat_name = category:match('^%((.-)%)$')
    274    if cat_name then
    275      for _, cat in ipairs(ERROR_CATEGORIES) do
    276        if cat == cat_name then
    277          if not error_suppressions[cat_name] then
    278            error_suppressions[cat_name] = {}
    279          end
    280          table.insert(error_suppressions[cat_name], linenum)
    281          break
    282        end
    283      end
    284    end
    285  end
    286 end
    287 
    288 local function reset_nolint_suppressions()
    289  error_suppressions = {}
    290 end
    291 
    292 local function reset_known_error_suppressions()
    293  error_suppressions_2 = {}
    294 end
    295 
    296 local function is_error_suppressed_by_nolint(category, linenum)
    297  local cat_suppressed = error_suppressions[category] or {}
    298  local all_suppressed = error_suppressions[vim.NIL] or {}
    299 
    300  for _, line in ipairs(cat_suppressed) do
    301    if line == linenum then
    302      return true
    303    end
    304  end
    305  for _, line in ipairs(all_suppressed) do
    306    if line == linenum then
    307      return true
    308    end
    309  end
    310  return false
    311 end
    312 
    313 local function is_error_in_suppressed_errors_list(category, linenum)
    314  local key = category .. ':' .. linenum
    315  return error_suppressions_2[key] == true
    316 end
    317 
    318 -- FileInfo class equivalent
    319 local FileInfo = {}
    320 FileInfo.__index = FileInfo
    321 
    322 function FileInfo.new(filename)
    323  local self = setmetatable({}, FileInfo)
    324  self._filename = filename
    325  return self
    326 end
    327 
    328 function FileInfo:full_name()
    329  local abspath = vim.fn.fnamemodify(self._filename, ':p')
    330  return abspath:gsub('\\', '/')
    331 end
    332 
    333 function FileInfo:relative_path()
    334  local fullname = self:full_name()
    335 
    336  if vim.fn.filereadable(fullname) == 1 then
    337    -- Find git repository root using vim.fs.root
    338    local git_root = vim.fs.root(fullname, '.git')
    339    if git_root then
    340      local root_dir = vim.fs.joinpath(git_root, 'src', 'nvim')
    341      local relpath = vim.fs.relpath(root_dir, fullname)
    342      if relpath then
    343        return relpath
    344      end
    345    end
    346  end
    347 
    348  return fullname
    349 end
    350 
    351 -- Error reporting
    352 local function should_print_error(category, confidence, linenum)
    353  if is_error_suppressed_by_nolint(category, linenum) then
    354    return false
    355  end
    356  if is_error_in_suppressed_errors_list(category, linenum) then
    357    return false
    358  end
    359  if confidence < cpplint_state.verbose_level then
    360    return false
    361  end
    362 
    363  local is_filtered = false
    364  for _, one_filter in ipairs(cpplint_state.filters) do
    365    if one_filter:sub(1, 1) == '-' then
    366      if category:find(one_filter:sub(2), 1, true) == 1 then
    367        is_filtered = true
    368      end
    369    elseif one_filter:sub(1, 1) == '+' then
    370      if category:find(one_filter:sub(2), 1, true) == 1 then
    371        is_filtered = false
    372      end
    373    end
    374  end
    375 
    376  return not is_filtered
    377 end
    378 
    379 local function error_func(filename, linenum, category, confidence, message)
    380  if should_print_error(category, confidence, linenum) then
    381    cpplint_state:increment_error_count(category)
    382 
    383    if cpplint_state.output_format == 'vs7' then
    384      io.write(
    385        string.format('%s(%s):  %s  [%s] [%d]\n', filename, linenum, message, category, confidence)
    386      )
    387    elseif cpplint_state.output_format == 'eclipse' then
    388      io.write(
    389        string.format(
    390          '%s:%s: warning: %s  [%s] [%d]\n',
    391          filename,
    392          linenum,
    393          message,
    394          category,
    395          confidence
    396        )
    397      )
    398    elseif cpplint_state.output_format == 'gh_action' then
    399      io.write(
    400        string.format(
    401          '::error file=%s,line=%s::%s  [%s] [%d]\n',
    402          filename,
    403          linenum,
    404          message,
    405          category,
    406          confidence
    407        )
    408      )
    409    else
    410      io.write(
    411        string.format('%s:%s:  %s  [%s] [%d]\n', filename, linenum, message, category, confidence)
    412      )
    413    end
    414  end
    415 end
    416 
    417 -- String processing functions
    418 local function is_cpp_string(line)
    419  line = line:gsub('\\\\', 'XX')
    420  local quote_count = select(2, line:gsub('"', ''))
    421  local escaped_quote_count = select(2, line:gsub('\\"', ''))
    422  local combined_quote_count = select(2, line:gsub("'\"'", ''))
    423  return ((quote_count - escaped_quote_count - combined_quote_count) % 2) == 1
    424 end
    425 
    426 local function cleanse_comments(line)
    427  local commentpos = line:find('//')
    428  if commentpos and not is_cpp_string(line:sub(1, commentpos - 1)) then
    429    line = line:sub(1, commentpos - 1):gsub('%s+$', '')
    430  end
    431  -- Remove /* */ comments
    432  line = line:gsub('/%*.-%*/', '')
    433  return line
    434 end
    435 
    436 -- CleansedLines class equivalent
    437 local CleansedLines = {}
    438 CleansedLines.__index = CleansedLines
    439 
    440 function CleansedLines.new(lines, init_lines)
    441  local self = setmetatable({}, CleansedLines)
    442  self.elided = {}
    443  self.lines = {}
    444  self.raw_lines = lines
    445  self._num_lines = #lines
    446  self.init_lines = init_lines
    447  self.lines_without_raw_strings = lines
    448  self.elided_with_space_strings = {}
    449 
    450  for linenum = 1, #self.lines_without_raw_strings do
    451    local line = self.lines_without_raw_strings[linenum]
    452    table.insert(self.lines, cleanse_comments(line))
    453 
    454    local elided = self:_collapse_strings(line)
    455    table.insert(self.elided, cleanse_comments(elided))
    456 
    457    local elided_spaces = self:_collapse_strings(line, true)
    458    table.insert(self.elided_with_space_strings, cleanse_comments(elided_spaces))
    459  end
    460 
    461  return self
    462 end
    463 
    464 function CleansedLines:num_lines()
    465  return self._num_lines
    466 end
    467 
    468 function CleansedLines:_collapse_strings(elided, keep_spaces)
    469  if not RE_PATTERN_INCLUDE:match_str(elided) then
    470    -- Remove escaped characters
    471    elided = elided:gsub('\\[abfnrtv?"\\\'\\]', keep_spaces and ' ' or '')
    472    elided = elided:gsub('\\%d+', keep_spaces and ' ' or '')
    473    elided = elided:gsub('\\x[0-9a-fA-F]+', keep_spaces and ' ' or '')
    474 
    475    if keep_spaces then
    476      elided = elided:gsub("'([^'])'", function(c)
    477        return "'" .. string.rep(' ', #c) .. "'"
    478      end)
    479      elided = elided:gsub('"([^"]*)"', function(c)
    480        return '"' .. string.rep(' ', #c) .. '"'
    481      end)
    482    else
    483      elided = elided:gsub("'([^'])'", "''")
    484      elided = elided:gsub('"([^"]*)"', '""')
    485    end
    486  end
    487  return elided
    488 end
    489 
    490 -- Helper functions for argument parsing
    491 local function print_usage(message)
    492  local usage = [[
    493 Syntax: clint.lua [--verbose=#] [--output=vs7] [--filter=-x,+y,...]
    494                 [--counting=total|toplevel|detailed] [--root=subdir]
    495                 [--linelength=digits] [--record-errors=file]
    496                 [--suppress-errors=file] [--stdin-filename=filename]
    497        <file> [file] ...
    498 
    499  The style guidelines this tries to follow are those in
    500    https://neovim.io/doc/user/dev_style.html#dev-style
    501 
    502  Note: This is Google's https://github.com/cpplint/cpplint modified for use
    503  with the Neovim project.
    504 
    505  Every problem is given a confidence score from 1-5, with 5 meaning we are
    506  certain of the problem, and 1 meaning it could be a legitimate construct.
    507  This will miss some errors, and is not a substitute for a code review.
    508 
    509  To suppress false-positive errors of a certain category, add a
    510  'NOLINT(category)' comment to the line.  NOLINT or NOLINT(*)
    511  suppresses errors of all categories on that line.
    512 
    513  The files passed in will be linted; at least one file must be provided.
    514  Default linted extensions are .cc, .cpp, .cu, .cuh and .h.  Change the
    515  extensions with the --extensions flag.
    516 
    517  Flags:
    518 
    519    output=vs7
    520      By default, the output is formatted to ease emacs parsing.  Visual Studio
    521      compatible output (vs7) may also be used.  Other formats are unsupported.
    522 
    523    verbose=#
    524      Specify a number 0-5 to restrict errors to certain verbosity levels.
    525 
    526    filter=-x,+y,...
    527      Specify a comma-separated list of category-filters to apply: only
    528      error messages whose category names pass the filters will be printed.
    529      (Category names are printed with the message and look like
    530      "[whitespace/indent]".)  Filters are evaluated left to right.
    531      "-FOO" and "FOO" means "do not print categories that start with FOO".
    532      "+FOO" means "do print categories that start with FOO".
    533 
    534      Examples: --filter=-whitespace,+whitespace/braces
    535                --filter=whitespace,runtime/printf,+runtime/printf_format
    536                --filter=-,+build/include_what_you_use
    537 
    538      To see a list of all the categories used in cpplint, pass no arg:
    539         --filter=
    540 
    541    counting=total|toplevel|detailed
    542      The total number of errors found is always printed. If
    543      'toplevel' is provided, then the count of errors in each of
    544      the top-level categories like 'build' and 'whitespace' will
    545      also be printed. If 'detailed' is provided, then a count
    546      is provided for each category.
    547 
    548    root=subdir
    549      The root directory used for deriving header guard CPP variable.
    550      By default, the header guard CPP variable is calculated as the relative
    551      path to the directory that contains .git, .hg, or .svn.  When this flag
    552      is specified, the relative path is calculated from the specified
    553      directory. If the specified directory does not exist, this flag is
    554      ignored.
    555 
    556      Examples:
    557        Assuing that src/.git exists, the header guard CPP variables for
    558        src/chrome/browser/ui/browser.h are:
    559 
    560        No flag => CHROME_BROWSER_UI_BROWSER_H_
    561        --root=chrome => BROWSER_UI_BROWSER_H_
    562        --root=chrome/browser => UI_BROWSER_H_
    563 
    564    linelength=digits
    565      This is the allowed line length for the project. The default value is
    566      80 characters.
    567 
    568      Examples:
    569        --linelength=120
    570 
    571    extensions=extension,extension,...
    572      The allowed file extensions that cpplint will check
    573 
    574      Examples:
    575        --extensions=hpp,cpp
    576 
    577    record-errors=file
    578      Record errors to the given location. This file may later be used for error
    579      suppression using suppress-errors flag.
    580 
    581    suppress-errors=file
    582      Errors listed in the given file will not be reported.
    583 
    584    stdin-filename=filename
    585      Use specified filename when reading from stdin (file "-").
    586 ]]
    587 
    588  if message then
    589    io.stderr:write(usage .. '\nFATAL ERROR: ' .. message .. '\n')
    590    os.exit(1)
    591  else
    592    io.write(usage)
    593    os.exit(0)
    594  end
    595 end
    596 
    597 local function print_categories()
    598  for _, cat in ipairs(ERROR_CATEGORIES) do
    599    io.write('  ' .. cat .. '\n')
    600  end
    601  os.exit(0)
    602 end
    603 
    604 -- Argument parsing
    605 local function parse_arguments(args)
    606  local filenames = {}
    607  local opts = {
    608    output_format = 'emacs',
    609    verbose_level = 1,
    610    filters = '',
    611    counting_style = 'total',
    612    extensions = { 'c', 'h' },
    613    record_errors_file = nil,
    614    suppress_errors_file = nil,
    615    stdin_filename = '',
    616  }
    617 
    618  local i = 1
    619  while i <= #args do
    620    local arg = args[i]
    621 
    622    if arg == '--help' then
    623      print_usage()
    624    elseif arg:sub(1, 9) == '--output=' then
    625      local format = arg:sub(10)
    626      if
    627        format ~= 'emacs'
    628        and format ~= 'vs7'
    629        and format ~= 'eclipse'
    630        and format ~= 'gh_action'
    631      then
    632        print_usage('The only allowed output formats are emacs, vs7 and eclipse.')
    633      end
    634      opts.output_format = format
    635    elseif arg:sub(1, 10) == '--verbose=' then
    636      opts.verbose_level = tonumber(arg:sub(11))
    637    elseif arg:sub(1, 9) == '--filter=' then
    638      opts.filters = arg:sub(10)
    639      if opts.filters == '' then
    640        print_categories()
    641      end
    642    elseif arg:sub(1, 12) == '--counting=' then
    643      local style = arg:sub(13)
    644      if style ~= 'total' and style ~= 'toplevel' and style ~= 'detailed' then
    645        print_usage('Valid counting options are total, toplevel, and detailed')
    646      end
    647      opts.counting_style = style
    648    elseif arg:sub(1, 13) == '--extensions=' then
    649      local exts = arg:sub(14)
    650      opts.extensions = {}
    651      for ext in vim.gsplit(exts, ',', { trimempty = true }) do
    652        table.insert(opts.extensions, vim.trim(ext))
    653      end
    654    elseif arg:sub(1, 16) == '--record-errors=' then
    655      opts.record_errors_file = arg:sub(17)
    656    elseif arg:sub(1, 18) == '--suppress-errors=' then
    657      opts.suppress_errors_file = arg:sub(19)
    658    elseif arg:sub(1, 17) == '--stdin-filename=' then
    659      opts.stdin_filename = arg:sub(18)
    660    elseif arg:sub(1, 2) == '--' then
    661      print_usage('Unknown option: ' .. arg)
    662    else
    663      table.insert(filenames, arg)
    664    end
    665 
    666    i = i + 1
    667  end
    668 
    669  if #filenames == 0 then
    670    print_usage('No files were specified.')
    671  end
    672 
    673  return filenames, opts
    674 end
    675 
    676 -- Lint checking functions
    677 
    678 local function find_next_multiline_comment_start(lines, lineix)
    679  while lineix <= #lines do
    680    if lines[lineix]:find('^%s*/%*') then
    681      if not lines[lineix]:find('%*/', 1, true) then
    682        -- Check if this line ends with backslash (line continuation)
    683        -- If so, don't treat it as a multiline comment start
    684        local line = lines[lineix]
    685        if not line:find('\\%s*$') then
    686          return lineix
    687        end
    688      end
    689    end
    690    lineix = lineix + 1
    691  end
    692  return #lines + 1
    693 end
    694 
    695 local function find_next_multiline_comment_end(lines, lineix)
    696  while lineix <= #lines do
    697    if lines[lineix]:find('%*/%s*$') then
    698      return lineix
    699    end
    700    lineix = lineix + 1
    701  end
    702  return #lines + 1
    703 end
    704 
    705 local function remove_multiline_comments_from_range(lines, begin, finish)
    706  for i = begin, finish do
    707    lines[i] = '// dummy'
    708  end
    709 end
    710 
    711 local function remove_multiline_comments(filename, lines, error)
    712  local lineix = 1
    713  while lineix <= #lines do
    714    local lineix_begin = find_next_multiline_comment_start(lines, lineix)
    715    if lineix_begin > #lines then
    716      return
    717    end
    718    local lineix_end = find_next_multiline_comment_end(lines, lineix_begin)
    719    if lineix_end > #lines then
    720      error(
    721        filename,
    722        lineix_begin,
    723        'readability/multiline_comment',
    724        5,
    725        'Could not find end of multi-line comment'
    726      )
    727      return
    728    end
    729    remove_multiline_comments_from_range(lines, lineix_begin, lineix_end)
    730    lineix = lineix_end + 1
    731  end
    732 end
    733 
    734 local function check_for_header_guard(filename, lines, error)
    735  if filename:match('%.c%.h$') or FileInfo.new(filename):relative_path() == 'func_attr.h' then
    736    return
    737  end
    738 
    739  local found_pragma = false
    740  for _, line in ipairs(lines) do
    741    if line:find('#pragma%s+once') then
    742      found_pragma = true
    743      break
    744    end
    745  end
    746 
    747  if not found_pragma then
    748    error(filename, 1, 'build/header_guard', 5, 'No "#pragma once" found in header')
    749  end
    750 end
    751 
    752 local function check_includes(filename, lines, error)
    753  if
    754    filename:match('%.c%.h$')
    755    or filename:match('%.in%.h$')
    756    or FileInfo.new(filename):relative_path() == 'func_attr.h'
    757    or FileInfo.new(filename):relative_path() == 'os/pty_proc.h'
    758  then
    759    return
    760  end
    761 
    762  local check_includes_ignore = {
    763    'src/nvim/api/private/validate.h',
    764    'src/nvim/assert_defs.h',
    765    'src/nvim/channel.h',
    766    'src/nvim/charset.h',
    767    'src/nvim/eval/typval.h',
    768    'src/nvim/event/multiqueue.h',
    769    'src/nvim/garray.h',
    770    'src/nvim/globals.h',
    771    'src/nvim/highlight.h',
    772    'src/nvim/lua/executor.h',
    773    'src/nvim/main.h',
    774    'src/nvim/mark.h',
    775    'src/nvim/msgpack_rpc/channel_defs.h',
    776    'src/nvim/msgpack_rpc/unpacker.h',
    777    'src/nvim/option.h',
    778    'src/nvim/os/pty_conpty_win.h',
    779    'src/nvim/os/pty_proc_win.h',
    780  }
    781 
    782  local skip_headers = {
    783    'auto/config.h',
    784    'klib/klist.h',
    785    'klib/kvec.h',
    786    'mpack/mpack_core.h',
    787    'mpack/object.h',
    788    'nvim/func_attr.h',
    789    'termkey/termkey.h',
    790    'vterm/vterm.h',
    791    'xdiff/xdiff.h',
    792  }
    793 
    794  for _, ignore in ipairs(check_includes_ignore) do
    795    if filename:match(ignore .. '$') then
    796      return
    797    end
    798  end
    799 
    800  for i, line in ipairs(lines) do
    801    local matched = match('#\\s*include\\s*"([^"]*)"', line)
    802    if matched then
    803      local name = line:match('#\\s*include\\s*"([^"]*)"')
    804      local should_skip = false
    805      for _, skip in ipairs(skip_headers) do
    806        if name == skip then
    807          should_skip = true
    808          break
    809        end
    810      end
    811 
    812      if
    813        not should_skip
    814        and not name:match('%.h%.generated%.h$')
    815        and not name:match('/defs%.h$')
    816        and not name:match('_defs%.h$')
    817        and not name:match('%.h%.inline%.generated%.h$')
    818        and not name:match('_defs%.generated%.h$')
    819        and not name:match('_enum%.generated%.h$')
    820      then
    821        error(
    822          filename,
    823          i - 1,
    824          'build/include_defs',
    825          5,
    826          'Headers should not include non-"_defs" headers'
    827        )
    828      end
    829    end
    830  end
    831 end
    832 
    833 local function check_non_symbols(filename, lines, error)
    834  for i, line in ipairs(lines) do
    835    if line:match('^EXTERN ') or line:match('^extern ') then
    836      error(
    837        filename,
    838        i - 1,
    839        'build/defs_header',
    840        5,
    841        '"_defs" headers should not contain extern variables'
    842      )
    843    end
    844  end
    845 end
    846 
    847 local function check_for_bad_characters(filename, lines, error)
    848  for linenum, line in ipairs(lines) do
    849    if line:find('\239\187\191') then -- UTF-8 BOM
    850      error(
    851        filename,
    852        linenum - 1,
    853        'readability/utf8',
    854        5,
    855        'Line contains invalid UTF-8 (or Unicode replacement character).'
    856      )
    857    end
    858    if line:find('\0') then
    859      error(filename, linenum - 1, 'readability/nul', 5, 'Line contains NUL byte.')
    860    end
    861  end
    862 end
    863 
    864 local function check_for_multiline_comments_and_strings(filename, clean_lines, linenum, error)
    865  -- Use elided line (with strings collapsed) to avoid false positives from /* */ in strings
    866  local line = clean_lines.elided[linenum + 1]
    867  if not line then
    868    return
    869  end
    870 
    871  -- Remove all \\ (escaped backslashes) from the line. They are OK, and the
    872  -- second (escaped) slash may trigger later \" detection erroneously.
    873  line = line:gsub('\\\\', '')
    874 
    875  local comment_count = select(2, line:gsub('/%*', ''))
    876  local comment_end_count = select(2, line:gsub('%*/', ''))
    877  -- Only warn if there are actually more opening than closing comments
    878  -- (accounting for the possibility that this is a multi-line comment that continues)
    879  if comment_count > comment_end_count and comment_count > 0 then
    880    error(
    881      filename,
    882      linenum,
    883      'readability/multiline_comment',
    884      5,
    885      'Complex multi-line /*...*/-style comment found. '
    886        .. 'Lint may give bogus warnings.  '
    887        .. 'Consider replacing these with //-style comments, '
    888        .. 'with #if 0...#endif, '
    889        .. 'or with more clearly structured multi-line comments.'
    890    )
    891  end
    892 
    893  -- Dropped 'readability/multiline_string' detection because it produces too many false positives
    894  -- with escaped quotes in C strings and character literals.
    895 end
    896 
    897 local function check_for_old_style_comments(filename, line, linenum, error)
    898  if line:find('/%*') and line:sub(-1) ~= '\\' and not RE_COMMENTLINE:match_str(line) then
    899    error(
    900      filename,
    901      linenum,
    902      'readability/old_style_comment',
    903      5,
    904      '/*-style comment found, it should be replaced with //-style.  '
    905        .. '/*-style comments are only allowed inside macros.  '
    906        .. 'Note that you should not use /*-style comments to document '
    907        .. 'macros itself, use doxygen-style comments for this.'
    908    )
    909  end
    910 end
    911 
    912 local function check_posix_threading(filename, clean_lines, linenum, error)
    913  local line = clean_lines.elided[linenum + 1]
    914 
    915  for _, pair in ipairs(threading_list) do
    916    local single_thread_function, multithread_safe_function = pair[1], pair[2]
    917    local start_pos = line:find(single_thread_function, 1, true)
    918 
    919    if start_pos then
    920      local prev_char = start_pos > 1 and line:sub(start_pos - 1, start_pos - 1) or ''
    921      if
    922        start_pos == 1
    923        or (
    924          not prev_char:match('%w')
    925          and prev_char ~= '_'
    926          and prev_char ~= '.'
    927          and prev_char ~= '>'
    928        )
    929      then
    930        error(
    931          filename,
    932          linenum,
    933          'runtime/threadsafe_fn',
    934          2,
    935          'Use '
    936            .. multithread_safe_function
    937            .. '...) instead of '
    938            .. single_thread_function
    939            .. '...). If it is missing, consider implementing it;'
    940            .. ' see os_localtime_r for an example.'
    941        )
    942      end
    943    end
    944  end
    945 end
    946 
    947 local function check_memory_functions(filename, clean_lines, linenum, error)
    948  if memory_ignore_pattern:match_str(filename) then
    949    return
    950  end
    951 
    952  local line = clean_lines.elided[linenum + 1]
    953 
    954  for _, pair in ipairs(memory_functions) do
    955    local func, suggested_func = pair[1], pair[2]
    956    local start_pos = line:find(func, 1, true)
    957 
    958    if start_pos then
    959      local prev_char = start_pos > 1 and line:sub(start_pos - 1, start_pos - 1) or ''
    960      if
    961        start_pos == 1
    962        or (
    963          not prev_char:match('%w')
    964          and prev_char ~= '_'
    965          and prev_char ~= '.'
    966          and prev_char ~= '>'
    967        )
    968      then
    969        error(
    970          filename,
    971          linenum,
    972          'runtime/memory_fn',
    973          2,
    974          'Use ' .. suggested_func .. '...) instead of ' .. func .. '...).'
    975        )
    976      end
    977    end
    978  end
    979 end
    980 
    981 local function check_os_functions(filename, clean_lines, linenum, error)
    982  local line = clean_lines.elided[linenum + 1]
    983 
    984  for _, pair in ipairs(os_functions) do
    985    local func, suggested_func = pair[1], pair[2]
    986    local start_pos = line:find(func, 1, true)
    987 
    988    if start_pos then
    989      local prev_char = start_pos > 1 and line:sub(start_pos - 1, start_pos - 1) or ''
    990      if
    991        start_pos == 1
    992        or (
    993          not prev_char:match('%w')
    994          and prev_char ~= '_'
    995          and prev_char ~= '.'
    996          and prev_char ~= '>'
    997        )
    998      then
    999        error(
   1000          filename,
   1001          linenum,
   1002          'runtime/os_fn',
   1003          2,
   1004          'Use ' .. suggested_func .. '...) instead of ' .. func .. '...).'
   1005        )
   1006      end
   1007    end
   1008  end
   1009 end
   1010 
   1011 local function check_language(filename, clean_lines, linenum, error)
   1012  local line = clean_lines.elided[linenum + 1]
   1013  if not line or line == '' then
   1014    return
   1015  end
   1016 
   1017  -- Check for verboten C basic types
   1018  local short_regex = vim.regex([[\<short\>]])
   1019  local long_long_regex = vim.regex([[\<long\> \+\<long\>]])
   1020 
   1021  if short_regex:match_str(line) then
   1022    error(
   1023      filename,
   1024      linenum,
   1025      'runtime/int',
   1026      4,
   1027      'Use int16_t/int64_t/etc, rather than the C type short'
   1028    )
   1029  elseif long_long_regex:match_str(line) then
   1030    error(
   1031      filename,
   1032      linenum,
   1033      'runtime/int',
   1034      4,
   1035      'Use int16_t/int64_t/etc, rather than the C type long long'
   1036    )
   1037  end
   1038 
   1039  -- Check for snprintf with non-zero literal size
   1040  local snprintf_match = line:match('snprintf%s*%([^,]*,%s*([0-9]+)%s*,')
   1041  if snprintf_match and snprintf_match ~= '0' then
   1042    error(
   1043      filename,
   1044      linenum,
   1045      'runtime/printf',
   1046      3,
   1047      'If you can, use sizeof(...) instead of ' .. snprintf_match .. ' as the 2nd arg to snprintf.'
   1048    )
   1049  end
   1050 
   1051  -- Check for sprintf (use vim.regex for proper word boundaries)
   1052  local sprintf_regex = vim.regex([[\<sprintf\>]])
   1053  if sprintf_regex:match_str(line) then
   1054    error(filename, linenum, 'runtime/printf', 5, 'Use snprintf instead of sprintf.')
   1055  end
   1056 
   1057  -- Check for strncpy (use vim.regex for proper word boundaries)
   1058  local strncpy_regex = vim.regex([[\<strncpy\>]])
   1059  local strncpy_upper_regex = vim.regex([[\<STRNCPY\>]])
   1060  if strncpy_regex:match_str(line) then
   1061    error(
   1062      filename,
   1063      linenum,
   1064      'runtime/printf',
   1065      4,
   1066      'Use xstrlcpy, xmemcpyz or snprintf instead of strncpy (unless this is from Vim)'
   1067    )
   1068  elseif strncpy_upper_regex:match_str(line) then
   1069    error(
   1070      filename,
   1071      linenum,
   1072      'runtime/printf',
   1073      4,
   1074      'Use xstrlcpy, xmemcpyz or snprintf instead of STRNCPY (unless this is from Vim)'
   1075    )
   1076  end
   1077 
   1078  -- Check for strcpy (use vim.regex for proper word boundaries)
   1079  local strcpy_regex = vim.regex([[\<strcpy\>]])
   1080  if strcpy_regex:match_str(line) then
   1081    error(
   1082      filename,
   1083      linenum,
   1084      'runtime/printf',
   1085      4,
   1086      'Use xstrlcpy, xmemcpyz or snprintf instead of strcpy'
   1087    )
   1088  end
   1089 
   1090  -- Check for memset with wrong argument order: memset(buf, sizeof(buf), 0)
   1091  -- Pattern: memset(arg1, arg2, 0) where arg2 is NOT a valid fill value
   1092  local memset_start = line:find('memset%s*%([^)]*,%s*[^,]*,%s*0%s*%)')
   1093  if memset_start then
   1094    -- Extract the full memset call
   1095    local memset_part = line:sub(memset_start)
   1096    local first_comma = memset_part:find(',')
   1097    if first_comma then
   1098      local after_first = memset_part:sub(first_comma + 1)
   1099      local second_comma = after_first:find(',')
   1100      if second_comma then
   1101        local second_arg = vim.trim(after_first:sub(1, second_comma - 1))
   1102        local first_arg = vim.trim(memset_part:match('memset%s*%(%s*([^,]*)%s*,'))
   1103 
   1104        -- Check if second_arg is NOT a simple literal value
   1105        if
   1106          second_arg ~= ''
   1107          and second_arg ~= "''"
   1108          and not second_arg:match('^%-?%d+$')
   1109          and not second_arg:match('^0x[0-9a-fA-F]+$')
   1110        then
   1111          error(
   1112            filename,
   1113            linenum,
   1114            'runtime/memset',
   1115            4,
   1116            'Did you mean "memset(' .. first_arg .. ', 0, ' .. second_arg .. ')"?'
   1117          )
   1118        end
   1119      end
   1120    end
   1121  end
   1122 
   1123  -- Detect variable-length arrays
   1124  -- Pattern: type varname[size]; where type is an identifier and varname starts with lowercase
   1125  local var_type = line:match('%s*(%w+)%s+')
   1126  if var_type and var_type ~= 'return' and var_type ~= 'delete' then
   1127    -- Look for array declaration pattern
   1128    local array_size = line:match('%w+%s+[a-z]%w*%s*%[([^%]]+)%]')
   1129    if array_size and not array_size:find('%]') then -- Ensure no nested brackets (multidimensional arrays)
   1130      -- Check if size is a compile-time constant
   1131      local is_const = true
   1132 
   1133      -- Split on common operators (space, +, -, *, /, <<, >>)
   1134      local tokens = vim.split(array_size, '[%s%+%-%*%/%>%<]+')
   1135 
   1136      for _, tok in ipairs(tokens) do
   1137        tok = vim.trim(tok)
   1138        if tok ~= '' then
   1139          -- Check for sizeof(...) and arraysize(...) or ARRAY_SIZE(...) patterns (before stripping parens)
   1140          local is_valid = tok:find('sizeof%(.+%)')
   1141            or tok:find('arraysize%(%w+%)')
   1142            or tok:find('ARRAY_SIZE%(.+%)')
   1143 
   1144          if not is_valid then
   1145            -- Strip leading and trailing parentheses for other checks
   1146            tok = tok:gsub('^%(*', ''):gsub('%)*$', '')
   1147            tok = vim.trim(tok)
   1148 
   1149            if tok ~= '' then
   1150              -- Allow: numeric literals, hex, k-prefixed constants, SCREAMING_CASE, sizeof, arraysize
   1151              is_valid = (
   1152                tok:match('^%d+$') -- decimal number
   1153                or tok:match('^0x[0-9a-fA-F]+$') -- hex number
   1154                or tok:match('^k[A-Z0-9]') -- k-prefixed constant
   1155                or tok:match('^[A-Z][A-Z0-9_]*$') -- SCREAMING_CASE
   1156                or tok:match('^sizeof') -- sizeof(...)
   1157                or tok:match('^arraysize')
   1158              ) -- arraysize(...)
   1159            end
   1160          end
   1161 
   1162          if not is_valid then
   1163            is_const = false
   1164            break
   1165          end
   1166        end
   1167      end
   1168 
   1169      if not is_const then
   1170        error(
   1171          filename,
   1172          linenum,
   1173          'runtime/arrays',
   1174          1,
   1175          "Do not use variable-length arrays.  Use an appropriately named ('k' followed by CamelCase) compile-time constant for the size."
   1176        )
   1177      end
   1178    end
   1179  end
   1180 
   1181  -- Check for TRUE/FALSE (use vim.regex for proper word boundaries)
   1182  local true_regex = vim.regex([[\<TRUE\>]])
   1183  local false_regex = vim.regex([[\<FALSE\>]])
   1184  local maybe_regex = vim.regex([[\<MAYBE\>]])
   1185 
   1186  if true_regex:match_str(line) then
   1187    error(filename, linenum, 'readability/bool', 4, 'Use true instead of TRUE.')
   1188  end
   1189 
   1190  if false_regex:match_str(line) then
   1191    error(filename, linenum, 'readability/bool', 4, 'Use false instead of FALSE.')
   1192  end
   1193 
   1194  -- Check for MAYBE
   1195  if maybe_regex:match_str(line) then
   1196    error(filename, linenum, 'readability/bool', 4, 'Use kNone from TriState instead of MAYBE.')
   1197  end
   1198 
   1199  -- Detect preincrement/predecrement at start of line
   1200  if line:match('^%s*%+%+') or line:match('^%s*%-%-') then
   1201    error(
   1202      filename,
   1203      linenum,
   1204      'readability/increment',
   1205      5,
   1206      'Do not use preincrement in statements, use postincrement instead'
   1207    )
   1208  end
   1209 
   1210  -- Detect preincrement/predecrement in for(;; preincrement)
   1211  -- Look for pattern like ";  ++var" or "; --var"
   1212  local last_semi_pos = 0
   1213  for i = 1, #line do
   1214    if line:sub(i, i) == ';' then
   1215      last_semi_pos = i
   1216    end
   1217  end
   1218 
   1219  if last_semi_pos > 0 then
   1220    -- Check if there's a preincrement/predecrement after the last semicolon
   1221    local after_semi = line:sub(last_semi_pos + 1)
   1222    local op_pos = after_semi:find('%+%+')
   1223    if not op_pos then
   1224      op_pos = after_semi:find('%-%-')
   1225    end
   1226    if op_pos then
   1227      -- Found preincrement/predecrement after last semicolon
   1228      local expr_start = after_semi:sub(1, op_pos - 1):match('^%s*(.*)')
   1229      if not expr_start or expr_start == '' then
   1230        -- Nothing but whitespace before operator, check the expression
   1231        local expr_text = after_semi:sub(op_pos)
   1232        if not expr_text:find(';') and not expr_text:find(' = ') then
   1233          error(
   1234            filename,
   1235            linenum,
   1236            'readability/increment',
   1237            4,
   1238            'Do not use preincrement in statements, including for(;; action)'
   1239          )
   1240        end
   1241      end
   1242    end
   1243  end
   1244 end
   1245 
   1246 local function check_for_non_standard_constructs(filename, clean_lines, linenum, error)
   1247  local line = clean_lines.lines[linenum + 1]
   1248 
   1249  -- Check for printf format issues with %q and %N$ in quoted strings
   1250  -- Extract all quoted strings and check their format specifiers
   1251  for str in line:gmatch('"([^"]*)"') do
   1252    -- Check for %q format (deprecated)
   1253    if str:find('%%%-?%+?%s?%d*q') then
   1254      error(
   1255        filename,
   1256        linenum,
   1257        'runtime/printf_format',
   1258        3,
   1259        '"%q" in format strings is deprecated.  Use "%" PRId64 instead.'
   1260      )
   1261    end
   1262 
   1263    -- Check for %N$ format (unconventional positional specifier)
   1264    if str:find('%%%d+%$') then
   1265      error(
   1266        filename,
   1267        linenum,
   1268        'runtime/printf_format',
   1269        2,
   1270        '%N$ formats are unconventional.  Try rewriting to avoid them.'
   1271      )
   1272    end
   1273  end
   1274 
   1275  -- Check for storage class order (type before storage class modifier)
   1276  -- Match type keywords followed by storage class keywords
   1277  local type_keywords = {
   1278    'const',
   1279    'volatile',
   1280    'void',
   1281    'char',
   1282    'short',
   1283    'int',
   1284    'long',
   1285    'float',
   1286    'double',
   1287    'signed',
   1288    'unsigned',
   1289  }
   1290  local storage_keywords = { 'register', 'static', 'extern', 'typedef' }
   1291 
   1292  for _, type_kw in ipairs(type_keywords) do
   1293    for _, storage_kw in ipairs(storage_keywords) do
   1294      local pattern = '\\<' .. type_kw .. '\\>\\s\\+\\<' .. storage_kw .. '\\>'
   1295      if vim.regex(pattern):match_str(line) then
   1296        error(
   1297          filename,
   1298          linenum,
   1299          'build/storage_class',
   1300          5,
   1301          'Storage class (static, extern, typedef, etc) should be first.'
   1302        )
   1303        return
   1304      end
   1305    end
   1306  end
   1307 
   1308  -- Check for endif comments
   1309  if line:match('^%s*#%s*endif%s*[^/\\s]+') then
   1310    error(
   1311      filename,
   1312      linenum,
   1313      'build/endif_comment',
   1314      5,
   1315      'Uncommented text after #endif is non-standard.  Use a comment.'
   1316    )
   1317  end
   1318 end
   1319 
   1320 -- Nesting state classes
   1321 local BlockInfo = {}
   1322 BlockInfo.__index = BlockInfo
   1323 
   1324 function BlockInfo.new(seen_open_brace)
   1325  local self = setmetatable({}, BlockInfo)
   1326  self.seen_open_brace = seen_open_brace
   1327  self.open_parentheses = 0
   1328  self.inline_asm = NO_ASM
   1329  return self
   1330 end
   1331 
   1332 local PreprocessorInfo = {}
   1333 PreprocessorInfo.__index = PreprocessorInfo
   1334 
   1335 function PreprocessorInfo.new(stack_before_if)
   1336  local self = setmetatable({}, PreprocessorInfo)
   1337  self.stack_before_if = stack_before_if
   1338  self.stack_before_else = {}
   1339  self.seen_else = false
   1340  return self
   1341 end
   1342 
   1343 local NestingState = {}
   1344 NestingState.__index = NestingState
   1345 
   1346 function NestingState.new()
   1347  local self = setmetatable({}, NestingState)
   1348  self.stack = {}
   1349  self.pp_stack = {}
   1350  return self
   1351 end
   1352 
   1353 function NestingState:seen_open_brace()
   1354  return #self.stack == 0 or self.stack[#self.stack].seen_open_brace
   1355 end
   1356 
   1357 function NestingState:update_preprocessor(line)
   1358  if line:match('^%s*#%s*(if|ifdef|ifndef)') then
   1359    table.insert(self.pp_stack, PreprocessorInfo.new(vim.deepcopy(self.stack)))
   1360  elseif line:match('^%s*#%s*(else|elif)') then
   1361    if #self.pp_stack > 0 then
   1362      if not self.pp_stack[#self.pp_stack].seen_else then
   1363        self.pp_stack[#self.pp_stack].seen_else = true
   1364        self.pp_stack[#self.pp_stack].stack_before_else = vim.deepcopy(self.stack)
   1365      end
   1366      self.stack = vim.deepcopy(self.pp_stack[#self.pp_stack].stack_before_if)
   1367    end
   1368  elseif line:match('^%s*#%s*endif') then
   1369    if #self.pp_stack > 0 then
   1370      if self.pp_stack[#self.pp_stack].seen_else then
   1371        self.stack = self.pp_stack[#self.pp_stack].stack_before_else
   1372      end
   1373      table.remove(self.pp_stack)
   1374    end
   1375  end
   1376 end
   1377 
   1378 function NestingState:update(clean_lines, linenum)
   1379  local line = clean_lines.elided[linenum + 1]
   1380 
   1381  self:update_preprocessor(line)
   1382 
   1383  if #self.stack > 0 then
   1384    local inner_block = self.stack[#self.stack]
   1385    local depth_change = select(2, line:gsub('%(', '')) - select(2, line:gsub('%)', ''))
   1386    inner_block.open_parentheses = inner_block.open_parentheses + depth_change
   1387 
   1388    if inner_block.inline_asm == NO_ASM or inner_block.inline_asm == END_ASM then
   1389      if depth_change ~= 0 and inner_block.open_parentheses == 1 and match_asm(line) then
   1390        inner_block.inline_asm = INSIDE_ASM
   1391      else
   1392        inner_block.inline_asm = NO_ASM
   1393      end
   1394    elseif inner_block.inline_asm == INSIDE_ASM and inner_block.open_parentheses == 0 then
   1395      inner_block.inline_asm = END_ASM
   1396    end
   1397  end
   1398 
   1399  while true do
   1400    local matched = line:match('^[^{;)}]*([{;)}])(.*)$')
   1401    if not matched then
   1402      break
   1403    end
   1404 
   1405    local token = matched:sub(1, 1)
   1406    if token == '{' then
   1407      if not self:seen_open_brace() then
   1408        self.stack[#self.stack].seen_open_brace = true
   1409      else
   1410        table.insert(self.stack, BlockInfo.new(true))
   1411        if match_asm(line) then
   1412          self.stack[#self.stack].inline_asm = BLOCK_ASM
   1413        end
   1414      end
   1415    elseif token == ';' or token == ')' then
   1416      if not self:seen_open_brace() then
   1417        table.remove(self.stack)
   1418      end
   1419    else -- token == '}'
   1420      if #self.stack > 0 then
   1421        table.remove(self.stack)
   1422      end
   1423    end
   1424    line = matched:sub(2)
   1425  end
   1426 end
   1427 
   1428 -- Main processing functions
   1429 local function process_line(
   1430  filename,
   1431  clean_lines,
   1432  line,
   1433  nesting_state,
   1434  error,
   1435  extra_check_functions
   1436 )
   1437  local raw_lines = clean_lines.raw_lines
   1438  local init_lines = clean_lines.init_lines
   1439 
   1440  parse_nolint_suppressions(raw_lines[line + 1], line)
   1441  nesting_state:update(clean_lines, line)
   1442 
   1443  if
   1444    #nesting_state.stack > 0 and nesting_state.stack[#nesting_state.stack].inline_asm ~= NO_ASM
   1445  then
   1446    return
   1447  end
   1448 
   1449  check_for_multiline_comments_and_strings(filename, clean_lines, line, error)
   1450  check_for_old_style_comments(filename, init_lines[line + 1], line, error)
   1451  check_language(filename, clean_lines, line, error)
   1452  check_for_non_standard_constructs(filename, clean_lines, line, error)
   1453  check_posix_threading(filename, clean_lines, line, error)
   1454  check_memory_functions(filename, clean_lines, line, error)
   1455  check_os_functions(filename, clean_lines, line, error)
   1456 
   1457  for _, check_fn in ipairs(extra_check_functions or {}) do
   1458    check_fn(filename, clean_lines, line, error)
   1459  end
   1460 end
   1461 
   1462 local function process_file_data(filename, file_extension, lines, error, extra_check_functions)
   1463  -- Add marker lines
   1464  table.insert(lines, 1, '// marker so line numbers and indices both start at 1')
   1465  table.insert(lines, '// marker so line numbers end in a known way')
   1466 
   1467  local nesting_state = NestingState.new()
   1468 
   1469  reset_nolint_suppressions()
   1470  reset_known_error_suppressions()
   1471 
   1472  local init_lines = vim.deepcopy(lines)
   1473 
   1474  if cpplint_state.record_errors_file then
   1475    local function recorded_error(filename_, linenum, category, confidence, message)
   1476      if not is_error_suppressed_by_nolint(category, linenum) then
   1477        local key_start = math.max(1, linenum)
   1478        local key_end = math.min(#lines, linenum + 2)
   1479        local key_lines = {}
   1480        for i = key_start, key_end do
   1481          table.insert(key_lines, lines[i])
   1482        end
   1483        local err = { filename_, key_lines, category }
   1484        cpplint_state.record_errors_file:write(vim.json.encode(err) .. '\n')
   1485      end
   1486      error(filename_, linenum, category, confidence, message)
   1487    end
   1488    error = recorded_error
   1489  end
   1490 
   1491  remove_multiline_comments(filename, lines, error)
   1492  local clean_lines = CleansedLines.new(lines, init_lines)
   1493 
   1494  for line = 0, clean_lines:num_lines() - 1 do
   1495    process_line(filename, clean_lines, line, nesting_state, error, extra_check_functions)
   1496  end
   1497 
   1498  if file_extension == 'h' then
   1499    check_for_header_guard(filename, lines, error)
   1500    check_includes(filename, lines, error)
   1501    if filename:match('/defs%.h$') or filename:match('_defs%.h$') then
   1502      check_non_symbols(filename, lines, error)
   1503    end
   1504  end
   1505 
   1506  check_for_bad_characters(filename, lines, error)
   1507 end
   1508 
   1509 local function process_file(filename, vlevel, extra_check_functions)
   1510  cpplint_state:set_verbose_level(vlevel)
   1511 
   1512  local lines
   1513 
   1514  if filename == '-' then
   1515    local stdin = io.read('*all')
   1516    lines = vim.split(stdin, '\n')
   1517    if cpplint_state.stdin_filename ~= '' then
   1518      filename = cpplint_state.stdin_filename
   1519    end
   1520  else
   1521    local ok, content = pcall(vim.fn.readfile, filename)
   1522    if not ok then
   1523      io.stderr:write("Skipping input '" .. filename .. "': Can't open for reading\n")
   1524      return
   1525    end
   1526    lines = content
   1527  end
   1528 
   1529  -- Remove trailing '\r'
   1530  for i, line in ipairs(lines) do
   1531    if line:sub(-1) == '\r' then
   1532      lines[i] = line:sub(1, -2)
   1533    end
   1534  end
   1535 
   1536  local file_extension = filename:match('^.+%.(.+)$') or ''
   1537 
   1538  if filename ~= '-' and not valid_extensions[file_extension] then
   1539    local ext_list = {}
   1540    for ext, _ in pairs(valid_extensions) do
   1541      table.insert(ext_list, '.' .. ext)
   1542    end
   1543    io.stderr:write(
   1544      'Ignoring ' .. filename .. '; only linting ' .. table.concat(ext_list, ', ') .. ' files\n'
   1545    )
   1546  else
   1547    process_file_data(filename, file_extension, lines, error_func, extra_check_functions)
   1548  end
   1549 end
   1550 
   1551 -- Main function
   1552 local function main(args)
   1553  local filenames, opts = parse_arguments(args)
   1554 
   1555  cpplint_state:set_output_format(opts.output_format)
   1556  cpplint_state:set_verbose_level(opts.verbose_level)
   1557  cpplint_state:set_filters(opts.filters)
   1558  cpplint_state:set_counting_style(opts.counting_style)
   1559  valid_extensions = {}
   1560  for _, ext in ipairs(opts.extensions) do
   1561    valid_extensions[ext] = true
   1562  end
   1563 
   1564  cpplint_state:suppress_errors_from(opts.suppress_errors_file)
   1565  cpplint_state:record_errors_to(opts.record_errors_file)
   1566  cpplint_state.stdin_filename = opts.stdin_filename
   1567 
   1568  cpplint_state:reset_error_counts()
   1569 
   1570  for _, filename in ipairs(filenames) do
   1571    process_file(filename, cpplint_state.verbose_level)
   1572  end
   1573 
   1574  cpplint_state:print_error_counts()
   1575 
   1576  if cpplint_state.record_errors_file then
   1577    cpplint_state.record_errors_file:close()
   1578  end
   1579 
   1580  vim.cmd.cquit(cpplint_state.error_count > 0 and 1 or 0)
   1581 end
   1582 
   1583 -- Export main function
   1584 main(_G.arg)