neovim

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

detect.lua (67646B)


      1 -- Contains filetype detection functions for use in filetype.lua that are either:
      2 --  * used more than once or
      3 --  * complex (e.g. check more than one line or use conditionals).
      4 -- Simple one-line checks, such as a check for a string in the first line are better inlined in filetype.lua.
      5 
      6 -- A few guidelines to follow when porting a new function:
      7 --  * Sort the function alphabetically and omit 'ft' or 'check' from the new function name.
      8 --  * Use ':find' instead of ':match' / ':sub' if possible.
      9 --  * When '=~' is used to match a pattern, there are two possibilities:
     10 --     - If the pattern only contains lowercase characters, treat the comparison as case-insensitive.
     11 --     - Otherwise, treat it as case-sensitive.
     12 --     (Basically, we apply 'smartcase': if upper case characters are used in the original pattern, then
     13 --     it's likely that case does matter).
     14 --  * When '\k', '\<' or '\>' is used in a pattern, use the 'matchregex' function.
     15 --     Note that vim.regex is case-sensitive by default, so add the '\c' flag if only lowercase letters
     16 --     are present in the pattern:
     17 --     Example:
     18 --     `if line =~ '^\s*unwind_protect\>'` => `if matchregex(line, [[\c^\s*unwind_protect\>]])`
     19 
     20 local fn = vim.fn
     21 local fs = vim.fs
     22 
     23 local M = {}
     24 
     25 local getlines = vim.filetype._getlines
     26 local getline = vim.filetype._getline
     27 local findany = vim.filetype._findany
     28 local nextnonblank = vim.filetype._nextnonblank
     29 local matchregex = vim.filetype._matchregex
     30 
     31 -- luacheck: push no unused args
     32 -- luacheck: push ignore 122
     33 
     34 -- Erlang Application Resource Files (*.app.src is matched by extension)
     35 -- See: https://erlang.org/doc/system/applications
     36 --- @type vim.filetype.mapfn
     37 function M.app(path, bufnr)
     38  if vim.g.filetype_app then
     39    return vim.g.filetype_app
     40  end
     41  for lnum, line in ipairs(getlines(bufnr, 1, 100)) do
     42    -- skip Erlang comments, might be something else
     43    if not findany(line, { '^%s*%%', '^%s*$' }) then
     44      if line:find('^%s*{') then
     45        local name = fn.fnamemodify(path, ':t:r:r')
     46        local lines = vim
     47          .iter(getlines(bufnr, lnum, lnum + 9))
     48          :filter(function(v)
     49            return not v:find('^%s*%%')
     50          end)
     51          :join(' ')
     52        if
     53          findany(lines, {
     54            [[^%s*{%s*application%s*,%s*']] .. name .. [['%s*,]],
     55            [[^%s*{%s*application%s*,%s*]] .. name .. [[%s*,]],
     56          })
     57        then
     58          return 'erlang'
     59        end
     60      end
     61      return
     62    end
     63  end
     64 end
     65 
     66 -- This function checks for the kind of assembly that is wanted by the user, or
     67 -- can be detected from the beginning of the file.
     68 --- @type vim.filetype.mapfn
     69 function M.asm(path, bufnr)
     70  local syntax = vim.b[bufnr].asmsyntax
     71  if not syntax or syntax == '' then
     72    syntax = M.asm_syntax(path, bufnr)
     73  end
     74 
     75  -- If b:asmsyntax still isn't set, default to asmsyntax or GNU
     76  if not syntax or syntax == '' then
     77    if vim.g.asmsyntax and vim.g.asmsyntax ~= 0 then
     78      syntax = vim.g.asmsyntax
     79    else
     80      syntax = 'asm'
     81    end
     82  end
     83  return syntax, function(b)
     84    vim.b[b].asmsyntax = syntax
     85  end
     86 end
     87 
     88 -- Checks the first lines for a asmsyntax=foo override.
     89 -- Only whitespace characters can be present immediately before or after this statement.
     90 --- @type vim.filetype.mapfn
     91 function M.asm_syntax(_, bufnr)
     92  local lines = ' ' .. table.concat(getlines(bufnr, 1, 5), ' '):lower() .. ' '
     93  local match = lines:match('%sasmsyntax=([a-zA-Z0-9]+)%s')
     94  if match then
     95    return match
     96  end
     97  local is_slash_star_encountered = false
     98  for _, line in ipairs(getlines(bufnr, 1, 50)) do
     99    if line:find('^/%*') then
    100      is_slash_star_encountered = true
    101    end
    102    if
    103      line:find('^; Listing generated by Microsoft')
    104      or matchregex(
    105        line,
    106        [[\c^\%(\%(CONST\|_BSS\|_DATA\|_TEXT\)\s\+SEGMENT\>\)\|\s*\.[2-6]86P\?\>\|\s*\.XMM\>]]
    107      )
    108    then
    109      return 'masm'
    110    elseif
    111      line:find('Texas Instruments Incorporated')
    112      -- tiasm uses `* comment`, but detection is unreliable if '/*' is seen
    113      or (line:find('^%*') and not is_slash_star_encountered)
    114    then
    115      return 'tiasm'
    116    elseif matchregex(line, [[\c\.title\>\|\.ident\>\|\.macro\>\|\.subtitle\>\|\.library\>]]) then
    117      return 'vmasm'
    118    end
    119  end
    120 end
    121 
    122 --- Active Server Pages (with Perl or Visual Basic Script)
    123 --- @type vim.filetype.mapfn
    124 function M.asp(_, bufnr)
    125  if vim.g.filetype_asp then
    126    return vim.g.filetype_asp
    127  elseif table.concat(getlines(bufnr, 1, 3)):lower():find('perlscript') then
    128    return 'aspperl'
    129  end
    130  return 'aspvbs'
    131 end
    132 
    133 local visual_basic_content =
    134  [[\c^\s*\%(Attribute\s\+VB_Name\|Begin\s\+\%(VB\.\|{\%(\x\+-\)\+\x\+}\)\)]]
    135 
    136 -- See frm() for Visual Basic form file detection
    137 --- @type vim.filetype.mapfn
    138 function M.bas(_, bufnr)
    139  if vim.g.filetype_bas then
    140    return vim.g.filetype_bas
    141  end
    142 
    143  -- Most frequent FreeBASIC-specific keywords in distro files
    144  local fb_keywords =
    145    [[\c^\s*\%(extern\|var\|enum\|private\|scope\|union\|byref\|operator\|constructor\|delete\|namespace\|public\|property\|with\|destructor\|using\)\>\%(\s*[:=(]\)\@!]]
    146  local fb_preproc =
    147    [[\c^\s*\%(#\s*\a\+\|option\s\+\%(byval\|dynamic\|escape\|\%(no\)\=gosub\|nokeyword\|private\|static\)\>\|\%(''\|rem\)\s*\$lang\>\|def\%(byte\|longint\|short\|ubyte\|uint\|ulongint\|ushort\)\>\)]]
    148 
    149  local fb_comment = "^%s*/'"
    150  -- OPTION EXPLICIT, without the leading underscore, is common to many dialects
    151  local qb64_preproc = [[\c^\s*\%($\a\+\|option\s\+\%(_explicit\|_\=explicitarray\)\>\)]]
    152 
    153  for _, line in ipairs(getlines(bufnr, 1, 100)) do
    154    if matchregex(line, visual_basic_content) then
    155      return 'vb'
    156    elseif
    157      line:find(fb_comment)
    158      or matchregex(line, fb_preproc)
    159      or matchregex(line, fb_keywords)
    160    then
    161      return 'freebasic'
    162    elseif matchregex(line, qb64_preproc) then
    163      return 'qb64'
    164    end
    165  end
    166  return 'basic'
    167 end
    168 
    169 --- @type vim.filetype.mapfn
    170 function M.bindzone(_, bufnr)
    171  local lines = table.concat(getlines(bufnr, 1, 4))
    172  if findany(lines, { '^; <<>> DiG [0-9%.]+.* <<>>', '%$ORIGIN', '%$TTL', 'IN%s+SOA' }) then
    173    return 'bindzone'
    174  end
    175 end
    176 
    177 -- Returns true if file content looks like RAPID
    178 --- @param bufnr integer
    179 --- @param extension? string
    180 --- @return string|boolean?
    181 local function is_rapid(bufnr, extension)
    182  if extension == 'cfg' then
    183    local line = getline(bufnr, 1):lower()
    184    return findany(line, { 'eio:cfg', 'mmc:cfg', 'moc:cfg', 'proc:cfg', 'sio:cfg', 'sys:cfg' })
    185  end
    186  local line = nextnonblank(bufnr, 1)
    187  if line then
    188    -- Called from mod, prg or sys functions
    189    return matchregex(line:lower(), [[\c\v^\s*%(\%{3}|module\s+\k+\s*%(\(|$))]])
    190  end
    191  return false
    192 end
    193 
    194 --- @type vim.filetype.mapfn
    195 function M.cfg(_, bufnr)
    196  if vim.g.filetype_cfg then
    197    return vim.g.filetype_cfg --[[@as string]]
    198  elseif is_rapid(bufnr, 'cfg') then
    199    return 'rapid'
    200  end
    201  return 'cfg'
    202 end
    203 
    204 --- This function checks if one of the first ten lines start with a '@'.  In
    205 --- that case it is probably a change file.
    206 --- If the first line starts with # or ! it's probably a ch file.
    207 --- If a line has "main", "include", "//" or "/*" it's probably ch.
    208 --- Otherwise CHILL is assumed.
    209 --- @type vim.filetype.mapfn
    210 function M.change(_, bufnr)
    211  local first_line = getline(bufnr, 1)
    212  if findany(first_line, { '^#', '^!' }) then
    213    return 'ch'
    214  end
    215  for _, line in ipairs(getlines(bufnr, 1, 10)) do
    216    if line:find('^@') then
    217      return 'change'
    218    end
    219    if line:find('MODULE') then
    220      return 'chill'
    221    elseif findany(line:lower(), { 'main%s*%(', '#%s*include', '//' }) then
    222      return 'ch'
    223    end
    224  end
    225  return 'chill'
    226 end
    227 
    228 --- @type vim.filetype.mapfn
    229 function M.changelog(_, bufnr)
    230  local line = getline(bufnr, 1):lower()
    231  if line:find('; urgency=') then
    232    return 'debchangelog'
    233  end
    234  return 'changelog'
    235 end
    236 
    237 --- @type vim.filetype.mapfn
    238 function M.cl(_, bufnr)
    239  local lines = table.concat(getlines(bufnr, 1, 4))
    240  if lines:match('/%*') then
    241    return 'opencl'
    242  else
    243    return 'lisp'
    244  end
    245 end
    246 
    247 --- @type vim.filetype.mapfn
    248 function M.class(_, bufnr)
    249  -- Check if not a Java class (starts with '\xca\xfe\xba\xbe')
    250  if not getline(bufnr, 1):find('^\202\254\186\190') then
    251    return 'stata'
    252  end
    253 end
    254 
    255 --- @type vim.filetype.mapfn
    256 function M.cls(_, bufnr)
    257  if vim.g.filetype_cls then
    258    return vim.g.filetype_cls
    259  end
    260  local line1 = getline(bufnr, 1)
    261  if matchregex(line1, [[^#!.*\<\%(rexx\|regina\)\>]]) then
    262    return 'rexx'
    263  elseif line1 == 'VERSION 1.0 CLASS' then
    264    return 'vb'
    265  end
    266 
    267  local nonblank1 = nextnonblank(bufnr, 1)
    268  if nonblank1 and nonblank1:find('^[%%\\]') then
    269    return 'tex'
    270  elseif nonblank1 and findany(nonblank1, { '^%s*/%*', '^%s*::%w' }) then
    271    return 'rexx'
    272  end
    273  return 'st'
    274 end
    275 
    276 --- *.cmd is close to a Batch file, but on OS/2 Rexx files and TI linker command files also use *.cmd.
    277 --- lnk: `/* comment */`, `// comment`, and `--linker-option=value`
    278 --- rexx: `/* comment */`, `-- comment`
    279 --- @type vim.filetype.mapfn
    280 function M.cmd(_, bufnr)
    281  local lines = table.concat(getlines(bufnr, 1, 20))
    282  if matchregex(lines, [[MEMORY\|SECTIONS\|\%(^\|\n\)--\S\|\%(^\|\n\)//]]) then
    283    return 'lnk'
    284  else
    285    local line1 = getline(bufnr, 1)
    286    if line1:find('^/%*') then
    287      return 'rexx'
    288    else
    289      return 'dosbatch'
    290    end
    291  end
    292 end
    293 
    294 --- @type vim.filetype.mapfn
    295 function M.conf(path, bufnr)
    296  if fn.did_filetype() ~= 0 or path:find(vim.g.ft_ignore_pat) then
    297    return
    298  end
    299  if path:find('%.conf$') then
    300    return 'conf'
    301  end
    302  for _, line in ipairs(getlines(bufnr, 1, 5)) do
    303    if line:find('^#') then
    304      return 'conf'
    305    end
    306  end
    307 end
    308 
    309 --- Debian Control
    310 --- @type vim.filetype.mapfn
    311 function M.control(_, bufnr)
    312  local line1 = getline(bufnr, 1)
    313  if line1 and findany(line1, { '^Source:', '^Package:' }) then
    314    return 'debcontrol'
    315  elseif line1 and findany(line1, { '^Tests:', '^Test%-Command:' }) then
    316    return 'autopkgtest'
    317  end
    318 end
    319 
    320 --- Debian Copyright
    321 --- @type vim.filetype.mapfn
    322 function M.copyright(_, bufnr)
    323  if getline(bufnr, 1):find('^Format:') then
    324    return 'debcopyright'
    325  end
    326 end
    327 
    328 --- @type vim.filetype.mapfn
    329 function M.cpp(_, _)
    330  return vim.g.cynlib_syntax_for_cpp and 'cynlib' or 'cpp'
    331 end
    332 
    333 --- @type vim.filetype.mapfn
    334 function M.csh(path, bufnr)
    335  if fn.did_filetype() ~= 0 then
    336    -- Filetype was already detected
    337    return
    338  end
    339  local contents = getlines(bufnr)
    340  if vim.g.filetype_csh then
    341    return M.shell(path, contents, vim.g.filetype_csh)
    342  elseif string.find(vim.o.shell, 'tcsh') then
    343    return M.shell(path, contents, 'tcsh')
    344  else
    345    return M.shell(path, contents, 'csh')
    346  end
    347 end
    348 
    349 --- @param path string
    350 --- @param contents string[]
    351 --- @return string?
    352 local function cvs_diff(path, contents)
    353  for _, line in ipairs(contents) do
    354    if not line:find('^%? ') then
    355      if matchregex(line, [[^Index:\s\+\f\+$]]) then
    356        -- CVS diff
    357        return 'diff'
    358      elseif
    359        -- Locale input files: Formal Definitions of Cultural Conventions
    360        -- Filename must be like en_US, fr_FR@euro or en_US.UTF-8
    361        findany(path, {
    362          '%a%a_%a%a$',
    363          '%a%a_%a%a[%.@]',
    364          '%a%a_%a%ai18n$',
    365          '%a%a_%a%aPOSIX$',
    366          '%a%a_%a%atranslit_',
    367        })
    368      then
    369        -- Only look at the first 100 lines
    370        for line_nr = 1, 100 do
    371          if not contents[line_nr] then
    372            break
    373          elseif
    374            findany(contents[line_nr], {
    375              '^LC_IDENTIFICATION$',
    376              '^LC_CTYPE$',
    377              '^LC_COLLATE$',
    378              '^LC_MONETARY$',
    379              '^LC_NUMERIC$',
    380              '^LC_TIME$',
    381              '^LC_MESSAGES$',
    382              '^LC_PAPER$',
    383              '^LC_TELEPHONE$',
    384              '^LC_MEASUREMENT$',
    385              '^LC_NAME$',
    386              '^LC_ADDRESS$',
    387            })
    388          then
    389            return 'fdcc'
    390          end
    391        end
    392      end
    393    end
    394  end
    395 end
    396 
    397 --- @type vim.filetype.mapfn
    398 function M.dat(path, bufnr)
    399  local file_name = fs.basename(path):lower()
    400  -- Innovation data processing
    401  if findany(file_name, { '^upstream%.dat$', '^upstream%..*%.dat$', '^.*%.upstream%.dat$' }) then
    402    return 'upstreamdat'
    403  end
    404  if vim.g.filetype_dat then
    405    return vim.g.filetype_dat
    406  end
    407  -- Determine if a *.dat file is Kuka Robot Language
    408  local line = nextnonblank(bufnr, 1)
    409  if matchregex(line, [[\c\v^\s*%(\&\w+|defdat>)]]) then
    410    return 'krl'
    411  end
    412 end
    413 
    414 --- @type vim.filetype.mapfn
    415 function M.decl(_, bufnr)
    416  for _, line in ipairs(getlines(bufnr, 1, 3)) do
    417    if line:lower():find('^<!sgml') then
    418      return 'sgmldecl'
    419    end
    420  end
    421 end
    422 
    423 -- This function is called for all files under */debian/patches/*, make sure not
    424 -- to non-dep3patch files, such as README and other text files.
    425 --- @type vim.filetype.mapfn
    426 function M.dep3patch(path, bufnr)
    427  local file_name = fs.basename(path)
    428  if file_name == 'series' then
    429    return
    430  end
    431 
    432  for _, line in ipairs(getlines(bufnr, 1, 100)) do
    433    if
    434      findany(line, {
    435        '^Description:',
    436        '^Subject:',
    437        '^Origin:',
    438        '^Bug:',
    439        '^Forwarded:',
    440        '^Author:',
    441        '^From:',
    442        '^Reviewed%-by:',
    443        '^Acked%-by:',
    444        '^Last%-Updated:',
    445        '^Applied%-Upstream:',
    446      })
    447    then
    448      return 'dep3patch'
    449    elseif line:find('^%-%-%-') then
    450      -- End of headers found. stop processing
    451      return
    452    end
    453  end
    454 end
    455 
    456 local function diff(contents)
    457  if
    458    contents[1]:find('^%-%-%- ') and contents[2]:find('^%+%+%+ ')
    459    or contents[1]:find('^%* looking for ') and contents[2]:find('^%* comparing to ')
    460    or contents[1]:find('^%*%*%* ') and contents[2]:find('^%-%-%- ')
    461    or contents[1]:find('^=== ') and ((contents[2]:find('^' .. string.rep('=', 66)) and contents[3]:find(
    462      '^%-%-% '
    463    ) and contents[4]:find('^%+%+%+')) or (contents[2]:find('^%-%-%- ') and contents[3]:find(
    464      '^%+%+%+ '
    465    )))
    466    or findany(contents[1], { '^=== removed', '^=== added', '^=== renamed', '^=== modified' })
    467  then
    468    return 'diff'
    469  end
    470 end
    471 
    472 local function dns_zone(contents)
    473  if
    474    findany(
    475      contents[1] .. contents[2] .. contents[3] .. contents[4],
    476      { '^; <<>> DiG [0-9%.]+.* <<>>', '%$ORIGIN', '%$TTL', 'IN%s+SOA' }
    477    )
    478  then
    479    return 'bindzone'
    480  end
    481  -- BAAN
    482  if -- Check for 1 to 80 '*' characters
    483    contents[1]:find('|%*' .. string.rep('%*?', 79)) and contents[2]:find('VRC ')
    484    or contents[2]:find('|%*' .. string.rep('%*?', 79)) and contents[3]:find('VRC ')
    485  then
    486    return 'baan'
    487  end
    488 end
    489 
    490 --- @type vim.filetype.mapfn
    491 function M.dtrace(_, bufnr)
    492  if fn.did_filetype() ~= 0 then
    493    -- Filetype was already detected
    494    return
    495  end
    496  for _, line in ipairs(getlines(bufnr, 1, 100)) do
    497    if matchregex(line, [[\c^module\>\|^import\>]]) then
    498      --  D files often start with a module and/or import statement.
    499      return 'd'
    500    elseif findany(line, { '^#!%S+dtrace', '#pragma%s+D%s+option', ':%S-:%S-:' }) then
    501      return 'dtrace'
    502    end
    503  end
    504  return 'd'
    505 end
    506 
    507 --- @param bufnr integer
    508 --- @return boolean
    509 local function is_modula2(bufnr)
    510  return matchregex(nextnonblank(bufnr, 1), [[\<MODULE\s\+\w\+\s*\%(\[.*]\s*\)\=;\|^\s*(\*]])
    511 end
    512 
    513 --- @param bufnr integer
    514 --- @return string, fun(b: integer)
    515 local function modula2(bufnr)
    516  local dialect = vim.g.modula2_default_dialect or 'pim'
    517  local extension = vim.g.modula2_default_extension or ''
    518 
    519  -- ignore unknown dialects or badly formatted tags
    520  for _, line in ipairs(getlines(bufnr, 1, 200)) do
    521    local matched_dialect, matched_extension = line:match('%(%*!m2(%w+)%+(%w+)%*%)')
    522    if not matched_dialect then
    523      matched_dialect = line:match('%(%*!m2(%w+)%*%)')
    524    end
    525    if matched_dialect then
    526      if vim.tbl_contains({ 'iso', 'pim', 'r10' }, matched_dialect) then
    527        dialect = matched_dialect
    528      end
    529      if vim.tbl_contains({ 'gm2' }, matched_extension) then
    530        extension = matched_extension
    531      end
    532      break
    533    end
    534  end
    535 
    536  return 'modula2',
    537    function(b)
    538      vim._with({ buf = b }, function()
    539        fn['modula2#SetDialect'](dialect, extension)
    540      end)
    541    end
    542 end
    543 
    544 --- @type vim.filetype.mapfn
    545 function M.def(_, bufnr)
    546  if getline(bufnr, 1):find('%%%%') then
    547    return 'tex'
    548  end
    549  if vim.g.filetype_def == 'modula2' or is_modula2(bufnr) then
    550    return modula2(bufnr)
    551  end
    552 
    553  if vim.g.filetype_def then
    554    return vim.g.filetype_def
    555  end
    556  return 'def'
    557 end
    558 
    559 --- @type vim.filetype.mapfn
    560 function M.dsp(path, bufnr)
    561  if vim.g.filetype_dsp then
    562    return vim.g.filetype_dsp
    563  end
    564 
    565  -- Test the filename
    566  local file_name = fs.basename(path)
    567  if file_name:find('^[mM]akefile.*$') then
    568    return 'make'
    569  end
    570 
    571  -- Test the file contents
    572  for _, line in ipairs(getlines(bufnr, 1, 200)) do
    573    if
    574      findany(line, {
    575        -- Check for comment style
    576        [[#.*]],
    577        -- Check for common lines
    578        [[^.*Microsoft Developer Studio Project File.*$]],
    579        [[^!MESSAGE This is not a valid makefile\..+$]],
    580        -- Check for keywords
    581        [[^!(IF,ELSEIF,ENDIF).*$]],
    582        -- Check for common assignments
    583        [[^SOURCE=.*$]],
    584      })
    585    then
    586      return 'make'
    587    end
    588  end
    589 
    590  -- Otherwise, assume we have a Faust file
    591  return 'faust'
    592 end
    593 
    594 --- @type vim.filetype.mapfn
    595 function M.e(_, bufnr)
    596  if vim.g.filetype_euphoria then
    597    return vim.g.filetype_euphoria
    598  end
    599  for _, line in ipairs(getlines(bufnr, 1, 100)) do
    600    if findany(line, { "^%s*<'%s*$", "^%s*'>%s*$" }) then
    601      return 'specman'
    602    end
    603  end
    604  return 'eiffel'
    605 end
    606 
    607 --- @type vim.filetype.mapfn
    608 function M.edn(_, bufnr)
    609  local line = getline(bufnr, 1)
    610  if matchregex(line, [[\c^\s*(\s*edif\>]]) then
    611    return 'edif'
    612  else
    613    return 'clojure'
    614  end
    615 end
    616 
    617 -- This function checks for valid cl syntax in the first five lines.
    618 -- Look for either an opening comment, '#', or a block start, '{'.
    619 -- If not found, assume SGML.
    620 --- @type vim.filetype.mapfn
    621 function M.ent(_, bufnr)
    622  for _, line in ipairs(getlines(bufnr, 1, 5)) do
    623    if line:find('^%s*[#{]') then
    624      return 'cl'
    625    elseif not line:find('^%s*$') then
    626      -- Not a blank line, not a comment, and not a block start,
    627      -- so doesn't look like valid cl code.
    628      break
    629    end
    630  end
    631  return 'dtd'
    632 end
    633 
    634 --- @type vim.filetype.mapfn
    635 function M.euphoria(_, _)
    636  return vim.g.filetype_euphoria or 'euphoria3'
    637 end
    638 
    639 --- @type vim.filetype.mapfn
    640 function M.ex(_, bufnr)
    641  if vim.g.filetype_euphoria then
    642    return vim.g.filetype_euphoria
    643  else
    644    for _, line in ipairs(getlines(bufnr, 1, 100)) do
    645      if matchregex(line, [[\c^--\|^ifdef\>\|^include\>]]) then
    646        return 'euphoria3'
    647      end
    648    end
    649    return 'elixir'
    650  end
    651 end
    652 
    653 --- @param bufnr integer
    654 --- @return boolean
    655 local function is_forth(bufnr)
    656  local first_line = nextnonblank(bufnr, 1)
    657 
    658  -- SwiftForth block comment (line is usually filled with '-' or '=') or
    659  -- OPTIONAL (sometimes precedes the header comment)
    660  if first_line and findany(first_line:lower(), { '^%{%s', '^%{$', '^optional%s' }) then
    661    return true
    662  end
    663 
    664  for _, line in ipairs(getlines(bufnr, 1, 100)) do
    665    -- Forth comments and colon definitions
    666    if line:find('^[:(\\] ') then
    667      return true
    668    end
    669  end
    670  return false
    671 end
    672 
    673 -- Distinguish between Forth and Fortran
    674 --- @type vim.filetype.mapfn
    675 function M.f(_, bufnr)
    676  if vim.g.filetype_f then
    677    return vim.g.filetype_f
    678  end
    679  if is_forth(bufnr) then
    680    return 'forth'
    681  end
    682  return 'fortran'
    683 end
    684 
    685 -- This function checks the first 15 lines for appearance of 'FoamFile'
    686 -- and then 'object' in a following line.
    687 -- In that case, it's probably an OpenFOAM file
    688 --- @type vim.filetype.mapfn
    689 function M.foam(_, bufnr)
    690  local foam_file = false
    691  for _, line in ipairs(getlines(bufnr, 1, 15)) do
    692    if line:find('^FoamFile') then
    693      foam_file = true
    694    elseif foam_file and line:find('^%s*object') then
    695      return 'foam'
    696    end
    697  end
    698 end
    699 
    700 --- @type vim.filetype.mapfn
    701 function M.frm(_, bufnr)
    702  if vim.g.filetype_frm then
    703    return vim.g.filetype_frm
    704  end
    705  if getline(bufnr, 1) == 'VERSION 5.00' then
    706    return 'vb'
    707  end
    708  for _, line in ipairs(getlines(bufnr, 1, 5)) do
    709    if matchregex(line, visual_basic_content) then
    710      return 'vb'
    711    end
    712  end
    713  return 'form'
    714 end
    715 
    716 --- @type vim.filetype.mapfn
    717 function M.fvwm_v1(_, _)
    718  return 'fvwm', function(bufnr)
    719    vim.b[bufnr].fvwm_version = 1
    720  end
    721 end
    722 
    723 --- @type vim.filetype.mapfn
    724 function M.fvwm_v2(_, _)
    725  return 'fvwm', function(bufnr)
    726    vim.b[bufnr].fvwm_version = 2
    727  end
    728 end
    729 
    730 -- Distinguish between Forth and F#
    731 --- @type vim.filetype.mapfn
    732 function M.fs(_, bufnr)
    733  if vim.g.filetype_fs then
    734    return vim.g.filetype_fs
    735  end
    736  if is_forth(bufnr) then
    737    return 'forth'
    738  end
    739  return 'fsharp'
    740 end
    741 
    742 --- @type vim.filetype.mapfn
    743 function M.git(_, bufnr)
    744  local line = getline(bufnr, 1)
    745  if matchregex(line, [[^\x\{40,\}\>\|^ref: ]]) then
    746    return 'git'
    747  end
    748 end
    749 
    750 --- @type vim.filetype.mapfn
    751 function M.header(_, bufnr)
    752  for _, line in ipairs(getlines(bufnr, 1, 200)) do
    753    if findany(line:lower(), { '^@interface', '^@end', '^@class' }) then
    754      if vim.g.c_syntax_for_h then
    755        return 'objc'
    756      else
    757        return 'objcpp'
    758      end
    759    end
    760  end
    761  if vim.g.c_syntax_for_h then
    762    return 'c'
    763  elseif vim.g.ch_syntax_for_h then
    764    return 'ch'
    765  else
    766    return 'cpp'
    767  end
    768 end
    769 
    770 --- Recursively search for Hare source files in a directory and any
    771 --- subdirectories, up to a given depth.
    772 --- @param dir string
    773 --- @param depth number
    774 --- @return boolean
    775 local function is_hare_module(dir, depth)
    776  depth = math.max(depth, 0)
    777  for name, _ in fs.dir(dir, { depth = depth + 1 }) do
    778    if name:find('%.ha$') then
    779      return true
    780    end
    781  end
    782  return false
    783 end
    784 
    785 --- @type vim.filetype.mapfn
    786 function M.haredoc(path, _)
    787  if vim.g.filetype_haredoc then
    788    if is_hare_module(fs.dirname(path), vim.g.haredoc_search_depth or 1) then
    789      return 'haredoc'
    790    end
    791  end
    792 end
    793 
    794 --- @type vim.filetype.mapfn
    795 function M.html(_, bufnr)
    796  -- Disabled for the reasons mentioned here:
    797  -- https://github.com/vim/vim/pull/13594#issuecomment-1834465890
    798  -- local filename = fn.fnamemodify(path, ':t')
    799  -- if filename:find('%.component%.html$') then
    800  --   return 'htmlangular'
    801  -- end
    802 
    803  for _, line in ipairs(getlines(bufnr, 1, 40)) do
    804    if
    805      matchregex(
    806        line,
    807        [[@\(if\|for\|defer\|switch\)\|\*\(ngIf\|ngFor\|ngSwitch\|ngTemplateOutlet\)\|ng-template\|ng-content]]
    808      )
    809    then
    810      return 'htmlangular'
    811    elseif matchregex(line, [[\<DTD\s\+XHTML\s]]) then
    812      return 'xhtml'
    813    elseif
    814      matchregex(
    815        line,
    816        [[\c{%\s*\(autoescape\|block\|comment\|csrf_token\|cycle\|debug\|extends\|filter\|firstof\|for\|if\|ifchanged\|include\|load\|lorem\|now\|query_string\|regroup\|resetcycle\|spaceless\|templatetag\|url\|verbatim\|widthratio\|with\)\>\|{#\s\+]]
    817      )
    818    then
    819      return 'htmldjango'
    820    elseif findany(line, { '<extend', '<super>' }) then
    821      return 'superhtml'
    822    end
    823  end
    824  return 'html'
    825 end
    826 
    827 -- Virata Config Script File or Drupal module
    828 --- @type vim.filetype.mapfn
    829 function M.hw(_, bufnr)
    830  if getline(bufnr, 1):lower():find('<%?php') then
    831    return 'php'
    832  end
    833  return 'virata'
    834 end
    835 
    836 -- This function checks for an assembly comment or a SWIG keyword or verbatim
    837 -- block in the first 50 lines.
    838 -- If not found, assume Progress.
    839 --- @type vim.filetype.mapfn
    840 function M.i(path, bufnr)
    841  if vim.g.filetype_i then
    842    return vim.g.filetype_i
    843  end
    844 
    845  -- These include the leading '%' sign
    846  local ft_swig_keywords =
    847    [[^\s*%\%(addmethods\|apply\|beginfile\|clear\|constant\|define\|echo\|enddef\|endoffile\|extend\|feature\|fragment\|ignore\|import\|importfile\|include\|includefile\|inline\|insert\|keyword\|module\|name\|namewarn\|native\|newobject\|parms\|pragma\|rename\|template\|typedef\|typemap\|types\|varargs\|warn\)]]
    848  -- This is the start/end of a block that is copied literally to the processor file (C/C++)
    849  local ft_swig_verbatim_block_start = '^%s*%%{'
    850 
    851  for _, line in ipairs(getlines(bufnr, 1, 50)) do
    852    if line:find('^%s*;') or line:find('^%*') then
    853      return M.asm(path, bufnr)
    854    elseif matchregex(line, ft_swig_keywords) or line:find(ft_swig_verbatim_block_start) then
    855      return 'swig'
    856    end
    857  end
    858  return 'progress'
    859 end
    860 
    861 --- @type vim.filetype.mapfn
    862 function M.idl(_, bufnr)
    863  for _, line in ipairs(getlines(bufnr, 1, 50)) do
    864    if findany(line:lower(), { '^%s*import%s+"unknwn"%.idl', '^%s*import%s+"objidl"%.idl' }) then
    865      return 'msidl'
    866    end
    867  end
    868  return 'idl'
    869 end
    870 
    871 local pascal_comments = { '^%s*{', '^%s*%(%*', '^%s*//' }
    872 local pascal_keywords =
    873  [[\c^\s*\%(program\|unit\|library\|uses\|begin\|procedure\|function\|const\|type\|var\)\>]]
    874 
    875 --- @type vim.filetype.mapfn
    876 function M.inc(path, bufnr)
    877  if vim.g.filetype_inc then
    878    return vim.g.filetype_inc
    879  end
    880  for _, line in ipairs(getlines(bufnr, 1, 20)) do
    881    if line:lower():find('perlscript') then
    882      return 'aspperl'
    883    elseif line:find('<%%') then
    884      return 'aspvbs'
    885    elseif line:find('<%?') then
    886      return 'php'
    887    -- Pascal supports // comments but they're vary rarely used for file
    888    -- headers so assume POV-Ray
    889    elseif findany(line, { '^%s{', '^%s%(%*' }) or matchregex(line, pascal_keywords) then
    890      return 'pascal'
    891    elseif
    892      findany(line, { '^%s*inherit ', '^%s*require ', '^%s*%u[%w_:${}/]*%s+%??[?:+.]?=.? ' })
    893    then
    894      return 'bitbake'
    895    end
    896  end
    897  local syntax = M.asm_syntax(path, bufnr)
    898  if not syntax or syntax == '' then
    899    return 'pov'
    900  end
    901  return syntax, function(b)
    902    vim.b[b].asmsyntax = syntax
    903  end
    904 end
    905 
    906 --- @type vim.filetype.mapfn
    907 function M.inp(_, bufnr)
    908  if getline(bufnr, 1):find('%%%%') then
    909    return 'tex'
    910  elseif getline(bufnr, 1):find('^%*') then
    911    return 'abaqus'
    912  else
    913    for _, line in ipairs(getlines(bufnr, 1, 500)) do
    914      if line:lower():find('^header surface data') then
    915        return 'trasys'
    916      end
    917    end
    918  end
    919 end
    920 
    921 --- @type vim.filetype.mapfn
    922 function M.install(path, bufnr)
    923  if getline(bufnr, 1):lower():find('<%?php') then
    924    return 'php'
    925  end
    926  return M.bash(path, bufnr)
    927 end
    928 
    929 --- Innovation Data Processing
    930 --- (refactor of filetype.vim since the patterns are case-insensitive)
    931 --- @type vim.filetype.mapfn
    932 function M.log(path, _)
    933  path = path:lower() --- @type string LuaLS bug
    934  if
    935    findany(
    936      path,
    937      { 'upstream%.log', 'upstream%..*%.log', '.*%.upstream%.log', 'upstream%-.*%.log' }
    938    )
    939  then
    940    return 'upstreamlog'
    941  elseif
    942    findany(
    943      path,
    944      { 'upstreaminstall%.log', 'upstreaminstall%..*%.log', '.*%.upstreaminstall%.log' }
    945    )
    946  then
    947    return 'upstreaminstalllog'
    948  elseif findany(path, { 'usserver%.log', 'usserver%..*%.log', '.*%.usserver%.log' }) then
    949    return 'usserverlog'
    950  elseif findany(path, { 'usw2kagt%.log', 'usw2kagt%..*%.log', '.*%.usw2kagt%.log' }) then
    951    return 'usw2kagtlog'
    952  end
    953 end
    954 
    955 --- @type vim.filetype.mapfn
    956 function M.ll(_, bufnr)
    957  local first_line = getline(bufnr, 1)
    958  if matchregex(first_line, [[;\|\<source_filename\>\|\<target\>]]) then
    959    return 'llvm'
    960  end
    961  for _, line in ipairs(getlines(bufnr, 1, 100)) do
    962    if line:find('^%s*%%') then
    963      return 'lex'
    964    end
    965  end
    966  return 'lifelines'
    967 end
    968 
    969 --- @type vim.filetype.mapfn
    970 function M.lpc(_, bufnr)
    971  if vim.g.lpc_syntax_for_c then
    972    for _, line in ipairs(getlines(bufnr, 1, 12)) do
    973      if
    974        findany(line, {
    975          '^//',
    976          '^inherit',
    977          '^private',
    978          '^protected',
    979          '^nosave',
    980          '^string',
    981          '^object',
    982          '^mapping',
    983          '^mixed',
    984        })
    985      then
    986        return 'lpc'
    987      end
    988    end
    989  end
    990  return 'c'
    991 end
    992 
    993 --- @type vim.filetype.mapfn
    994 function M.lsl(_, bufnr)
    995  if vim.g.filetype_lsl then
    996    return vim.g.filetype_lsl
    997  end
    998 
    999  local line = nextnonblank(bufnr, 1)
   1000  if findany(line, { '^%s*%%', ':%s*trait%s*$' }) then
   1001    return 'larch'
   1002  else
   1003    return 'lsl'
   1004  end
   1005 end
   1006 
   1007 --- @type vim.filetype.mapfn
   1008 function M.m(_, bufnr)
   1009  if vim.g.filetype_m then
   1010    return vim.g.filetype_m
   1011  end
   1012 
   1013  -- Excluding end(for|function|if|switch|while) common to Murphi
   1014  local octave_block_terminators =
   1015    [[\<end\%(_try_catch\|classdef\|enumeration\|events\|methods\|parfor\|properties\)\>]]
   1016  local objc_preprocessor =
   1017    [[\c^\s*#\s*\%(import\|include\|define\|if\|ifn\=def\|undef\|line\|error\|pragma\)\>]]
   1018 
   1019  -- Whether we've seen a multiline comment leader
   1020  local saw_comment = false
   1021  for _, line in ipairs(getlines(bufnr, 1, 100)) do
   1022    if line:find('^%s*/%*') then
   1023      -- /* ... */ is a comment in Objective C and Murphi, so we can't conclude
   1024      -- it's either of them yet, but track this as a hint in case we don't see
   1025      -- anything more definitive.
   1026      saw_comment = true
   1027    end
   1028    if
   1029      line:find('^%s*//')
   1030      or matchregex(line, [[\c^\s*@import\>]])
   1031      or matchregex(line, objc_preprocessor)
   1032    then
   1033      return 'objc'
   1034    end
   1035    if
   1036      findany(line, { '^%s*#', '^%s*%%!' })
   1037      or matchregex(line, [[\c^\s*unwind_protect\>]])
   1038      or matchregex(line, [[\c\%(^\|;\)\s*]] .. octave_block_terminators)
   1039    then
   1040      return 'octave'
   1041    elseif line:find('^%s*%%') then
   1042      return 'matlab'
   1043    elseif line:find('^%s*%(%*') then
   1044      return 'mma'
   1045    elseif matchregex(line, [[\c^\s*\(\(type\|var\)\>\|--\)]]) then
   1046      return 'murphi'
   1047    end
   1048  end
   1049 
   1050  if saw_comment then
   1051    -- We didn't see anything definitive, but this looks like either Objective C
   1052    -- or Murphi based on the comment leader. Assume the former as it is more
   1053    -- common.
   1054    return 'objc'
   1055  else
   1056    -- Default is Matlab
   1057    return 'matlab'
   1058  end
   1059 end
   1060 
   1061 --- For files ending in *.m4, distinguish:
   1062 ---  – *.html.m4 files
   1063 ---  - *fvwm2rc*.m4 files
   1064 ---  – files in the Autoconf M4 dialect
   1065 ---  – files in POSIX M4
   1066 --- @type vim.filetype.mapfn
   1067 function M.m4(path, bufnr)
   1068  local fname = fs.basename(path)
   1069  path = fs.dirname(fs.abspath(path))
   1070 
   1071  if fname:find('html%.m4$') then
   1072    return 'htmlm4'
   1073  end
   1074 
   1075  if fname:find('fvwm2rc') then
   1076    return 'fvwm2m4'
   1077  end
   1078 
   1079  -- Canonical Autoconf file
   1080  if fname == 'aclocal.m4' then
   1081    return 'config'
   1082  end
   1083 
   1084  -- Repo heuristic for Autoconf M4 (nearby configure.ac)
   1085  if
   1086    fn.filereadable(path .. '/../configure.ac') ~= 0
   1087    or fn.filereadable(path .. '/configure.ac') ~= 0
   1088  then
   1089    return 'config'
   1090  end
   1091 
   1092  -- Content heuristic for Autoconf M4 (scan first ~200 lines)
   1093  -- Signals:
   1094  --   - Autoconf macro prefixes: AC_/AM_/AS_/AU_/AT_
   1095  for _, line in ipairs(getlines(bufnr, 1, 200)) do
   1096    if line:find('^%s*A[CMSUT]_') then
   1097      return 'config'
   1098    end
   1099  end
   1100 
   1101  -- Default to POSIX M4
   1102  return 'm4'
   1103 end
   1104 
   1105 --- @param contents string[]
   1106 --- @return string?
   1107 local function m4(contents)
   1108  for _, line in ipairs(contents) do
   1109    if matchregex(line, [[^\s*dnl\>]]) then
   1110      return 'm4'
   1111    end
   1112  end
   1113  if vim.env.TERM == 'amiga' and findany(assert(contents[1]):lower(), { '^;', '^%.bra' }) then
   1114    -- AmigaDos scripts
   1115    return 'amiga'
   1116  end
   1117 end
   1118 
   1119 --- Check if it is a Microsoft Makefile
   1120 --- @type vim.filetype.mapfn
   1121 function M.make(path, bufnr)
   1122  vim.b.make_flavor = nil
   1123 
   1124  -- 1. filename
   1125  local file_name = fs.basename(path)
   1126  if file_name == 'BSDmakefile' then
   1127    vim.b.make_flavor = 'bsd'
   1128    return 'make'
   1129  elseif file_name == 'GNUmakefile' then
   1130    vim.b.make_flavor = 'gnu'
   1131    return 'make'
   1132  end
   1133 
   1134  -- 2. user's setting
   1135  if vim.g.make_flavor ~= nil then
   1136    vim.b.make_flavor = vim.g.make_flavor
   1137    return 'make'
   1138  elseif vim.g.make_microsoft ~= nil then
   1139    vim._truncated_echo_once(
   1140      "make_microsoft is deprecated; try g:make_flavor = 'microsoft' instead"
   1141    )
   1142    vim.b.make_flavor = 'microsoft'
   1143    return 'make'
   1144  end
   1145 
   1146  -- 3. try to detect a flavor from file content
   1147  for _, line in ipairs(getlines(bufnr, 1, 1000)) do
   1148    if matchregex(line, [[\c^\s*!\s*\(ifn\=\(def\)\=\|include\|message\|error\)\>]]) then
   1149      vim.b.make_flavor = 'microsoft'
   1150      break
   1151    elseif
   1152      matchregex(line, [[^\.\%(export\|error\|for\|if\%(n\=\%(def\|make\)\)\=\|info\|warning\)\>]])
   1153    then
   1154      vim.b.make_flavor = 'bsd'
   1155      break
   1156    elseif
   1157      matchregex(line, [[^ *\%(ifn\=\%(eq\|def\)\|define\|override\)\>]])
   1158      or line:find('%$[({][a-z-]+%s+%S+') -- a function call, e.g. $(shell pwd)
   1159    then
   1160      vim.b.make_flavor = 'gnu'
   1161      break
   1162    end
   1163  end
   1164  return 'make'
   1165 end
   1166 
   1167 --- @type vim.filetype.mapfn
   1168 function M.markdown(_, _)
   1169  return vim.g.filetype_md or 'markdown'
   1170 end
   1171 
   1172 --- Rely on the file to start with a comment.
   1173 --- MS message text files use ';', Sendmail files use '#' or 'dnl'
   1174 --- @type vim.filetype.mapfn
   1175 function M.mc(_, bufnr)
   1176  for _, line in ipairs(getlines(bufnr, 1, 20)) do
   1177    if findany(line:lower(), { '^%s*#', '^%s*dnl' }) then
   1178      -- Sendmail .mc file
   1179      return 'm4'
   1180    elseif line:find('^%s*;') then
   1181      return 'msmessages'
   1182    end
   1183  end
   1184  -- Default: Sendmail .mc file
   1185  return 'm4'
   1186 end
   1187 
   1188 --- @param path string
   1189 --- @return string?
   1190 function M.me(path)
   1191  local filename = fs.basename(path):lower()
   1192  if filename ~= 'read.me' and filename ~= 'click.me' then
   1193    return 'nroff'
   1194  end
   1195 end
   1196 
   1197 --- @type vim.filetype.mapfn
   1198 function M.mm(_, bufnr)
   1199  for _, line in ipairs(getlines(bufnr, 1, 20)) do
   1200    if matchregex(line, [[\c^\s*\(#\s*\(include\|import\)\>\|@import\>\|/\*\)]]) then
   1201      return 'objcpp'
   1202    end
   1203  end
   1204  return 'nroff'
   1205 end
   1206 
   1207 --- @type vim.filetype.mapfn
   1208 function M.mms(_, bufnr)
   1209  for _, line in ipairs(getlines(bufnr, 1, 20)) do
   1210    if findany(line, { '^%s*%%', '^%s*//', '^%*' }) then
   1211      return 'mmix'
   1212    elseif line:find('^%s*#') then
   1213      return 'make'
   1214    end
   1215  end
   1216  return 'mmix'
   1217 end
   1218 
   1219 --- Returns true if file content looks like LambdaProlog
   1220 --- @param bufnr integer
   1221 --- @return boolean
   1222 local function is_lprolog(bufnr)
   1223  -- Skip apparent comments and blank lines, what looks like
   1224  -- LambdaProlog comment may be RAPID header
   1225  for _, line in ipairs(getlines(bufnr)) do
   1226    -- The second pattern matches a LambdaProlog comment
   1227    if not findany(line, { '^%s*$', '^%s*%%' }) then
   1228      -- The pattern must not catch a go.mod file
   1229      return matchregex(line, [[\c\<module\s\+\w\+\s*\.\s*\(%\|$\)]])
   1230    end
   1231  end
   1232  return false
   1233 end
   1234 
   1235 --- Determine if *.mod is ABB RAPID, LambdaProlog, Modula-2, Modsim III or go.mod
   1236 --- @type vim.filetype.mapfn
   1237 function M.mod(path, bufnr)
   1238  if vim.g.filetype_mod == 'modula2' or is_modula2(bufnr) then
   1239    return modula2(bufnr)
   1240  end
   1241 
   1242  if vim.g.filetype_mod then
   1243    return vim.g.filetype_mod
   1244  elseif matchregex(path, [[\c\<go\.mod$]]) then
   1245    return 'gomod'
   1246  elseif is_lprolog(bufnr) then
   1247    return 'lprolog'
   1248  elseif is_rapid(bufnr) then
   1249    return 'rapid'
   1250  end
   1251  -- Nothing recognized, assume modsim3
   1252  return 'modsim3'
   1253 end
   1254 
   1255 --- Determine if *.mod is ABB RAPID, LambdaProlog, Modula-2, Modsim III or go.mod
   1256 --- @type vim.filetype.mapfn
   1257 function M.mp(_, _)
   1258  return 'mp', function(b)
   1259    vim.b[b].mp_metafun = 1
   1260  end
   1261 end
   1262 
   1263 --- @type vim.filetype.mapfn
   1264 function M.news(_, bufnr)
   1265  if getline(bufnr, 1):lower():find('; urgency=') then
   1266    return 'debchangelog'
   1267  end
   1268 end
   1269 
   1270 --- This function checks if one of the first five lines start with a typical
   1271 --- nroff pattern in man files.  In that case it is probably an nroff file.
   1272 --- @type vim.filetype.mapfn
   1273 function M.nroff(_, bufnr)
   1274  for _, line in ipairs(getlines(bufnr, 1, 5)) do
   1275    if
   1276      matchregex(
   1277        line,
   1278        [[^\%([.']\s*\%(TH\|D[dt]\|S[Hh]\|d[es]1\?\|so\)\s\+\S\|[.'']\s*ig\>\|\%([.'']\s*\)\?\\"\)]]
   1279      )
   1280    then
   1281      return 'nroff'
   1282    end
   1283  end
   1284 end
   1285 
   1286 --- @type vim.filetype.mapfn
   1287 function M.patch(_, bufnr)
   1288  local firstline = getline(bufnr, 1)
   1289  if string.find(firstline, '^From ' .. string.rep('%x', 40) .. '+ Mon Sep 17 00:00:00 2001$') then
   1290    return 'gitsendemail'
   1291  end
   1292  return 'diff'
   1293 end
   1294 
   1295 --- If the file has an extension of 't' and is in a directory 't' or 'xt' then
   1296 --- it is almost certainly a Perl test file.
   1297 --- If the first line starts with '#' and contains 'perl' it's probably a Perl file.
   1298 --- (Slow test) If a file contains a 'use' statement then it is almost certainly a Perl file.
   1299 --- @type vim.filetype.mapfn
   1300 function M.perl(path, bufnr)
   1301  local dir_name = fs.dirname(path)
   1302  if fn.fnamemodify(path, '%:e') == 't' and (dir_name == 't' or dir_name == 'xt') then
   1303    return 'perl'
   1304  end
   1305  local first_line = getline(bufnr, 1)
   1306  if first_line:find('^#') and first_line:lower():find('perl') then
   1307    return 'perl'
   1308  end
   1309  for _, line in ipairs(getlines(bufnr, 1, 30)) do
   1310    if matchregex(line, [[\c^use\s\s*\k]]) then
   1311      return 'perl'
   1312    end
   1313  end
   1314 end
   1315 
   1316 local prolog_patterns = { '^%s*:%-', '^%s*%%+%s', '^%s*%%+$', '^%s*/%*', '%.%s*$' }
   1317 
   1318 --- @type vim.filetype.mapfn
   1319 function M.pl(_, bufnr)
   1320  if vim.g.filetype_pl then
   1321    return vim.g.filetype_pl
   1322  end
   1323  -- Recognize Prolog by specific text in the first non-empty line;
   1324  -- require a blank after the '%' because Perl uses "%list" and "%translate"
   1325  local line = nextnonblank(bufnr, 1)
   1326  if line and matchregex(line, [[\c\<prolog\>]]) or findany(line, prolog_patterns) then
   1327    return 'prolog'
   1328  else
   1329    return 'perl'
   1330  end
   1331 end
   1332 
   1333 --- @type vim.filetype.mapfn
   1334 function M.pm(_, bufnr)
   1335  local line = getline(bufnr, 1)
   1336  if line:find('XPM2') then
   1337    return 'xpm2'
   1338  elseif line:find('XPM') then
   1339    return 'xpm'
   1340  else
   1341    return 'perl'
   1342  end
   1343 end
   1344 
   1345 --- @type vim.filetype.mapfn
   1346 function M.pp(_, bufnr)
   1347  if vim.g.filetype_pp then
   1348    return vim.g.filetype_pp
   1349  end
   1350  local line = nextnonblank(bufnr, 1)
   1351  if findany(line, pascal_comments) or matchregex(line, pascal_keywords) then
   1352    return 'pascal'
   1353  else
   1354    return 'puppet'
   1355  end
   1356 end
   1357 
   1358 --- @type vim.filetype.mapfn
   1359 function M.prg(_, bufnr)
   1360  if vim.g.filetype_prg then
   1361    return vim.g.filetype_prg
   1362  elseif is_rapid(bufnr) then
   1363    return 'rapid'
   1364  else
   1365    -- Nothing recognized, assume Clipper
   1366    return 'clipper'
   1367  end
   1368 end
   1369 
   1370 function M.printcap(ptcap_type)
   1371  if fn.did_filetype() == 0 then
   1372    return 'ptcap', function(bufnr)
   1373      vim.b[bufnr].ptcap_type = ptcap_type
   1374    end
   1375  end
   1376 end
   1377 
   1378 --- @type vim.filetype.mapfn
   1379 function M.progress_cweb(_, bufnr)
   1380  if vim.g.filetype_w then
   1381    return vim.g.filetype_w
   1382  else
   1383    if
   1384      getline(bufnr, 1):lower():find('^&analyze')
   1385      or getline(bufnr, 3):lower():find('^&global%-define')
   1386    then
   1387      return 'progress'
   1388    else
   1389      return 'cweb'
   1390    end
   1391  end
   1392 end
   1393 
   1394 -- This function checks for valid Pascal syntax in the first 10 lines.
   1395 -- Look for either an opening comment or a program start.
   1396 -- If not found, assume Progress.
   1397 --- @type vim.filetype.mapfn
   1398 function M.progress_pascal(_, bufnr)
   1399  if vim.g.filetype_p then
   1400    return vim.g.filetype_p
   1401  end
   1402  for _, line in ipairs(getlines(bufnr, 1, 10)) do
   1403    if findany(line, pascal_comments) or matchregex(line, pascal_keywords) then
   1404      return 'pascal'
   1405    elseif not line:find('^%s*$') or line:find('^/%*') then
   1406      -- Not an empty line: Doesn't look like valid Pascal code.
   1407      -- Or it looks like a Progress /* comment
   1408      break
   1409    end
   1410  end
   1411  return 'progress'
   1412 end
   1413 
   1414 --- Distinguish between "default", Prolog and Cproto prototype file.
   1415 --- @type vim.filetype.mapfn
   1416 function M.proto(_, bufnr)
   1417  if getline(bufnr, 2):find('/%* Generated automatically %*/') then
   1418    return 'c'
   1419  elseif getline(bufnr, 2):find('.;$') then
   1420    -- Cproto files have a comment in the first line and a function prototype in
   1421    -- the second line, it always ends in ";".  Indent files may also have
   1422    -- comments, thus we can't match comments to see the difference.
   1423    -- IDL files can have a single ';' in the second line, require at least one
   1424    -- character before the ';'.
   1425    return 'cpp'
   1426  end
   1427  -- Recognize Prolog by specific text in the first non-empty line;
   1428  -- require a blank after the '%' because Perl uses "%list" and "%translate"
   1429  local line = nextnonblank(bufnr, 1)
   1430  if line and matchregex(line, [[\c\<prolog\>]]) or findany(line, prolog_patterns) then
   1431    return 'prolog'
   1432  end
   1433 end
   1434 
   1435 -- Software Distributor Product Specification File (POSIX 1387.2-1995)
   1436 --- @type vim.filetype.mapfn
   1437 function M.psf(_, bufnr)
   1438  local line = getline(bufnr, 1):lower()
   1439  if
   1440    findany(line, {
   1441      '^%s*distribution%s*$',
   1442      '^%s*installed_software%s*$',
   1443      '^%s*root%s*$',
   1444      '^%s*bundle%s*$',
   1445      '^%s*product%s*$',
   1446    })
   1447  then
   1448    return 'psf'
   1449  end
   1450 end
   1451 
   1452 --- @type vim.filetype.mapfn
   1453 function M.r(_, bufnr)
   1454  local lines = getlines(bufnr, 1, 50)
   1455  -- Rebol is easy to recognize, check for that first
   1456  if matchregex(table.concat(lines), [[\c\<rebol\>]]) then
   1457    return 'rebol'
   1458  end
   1459 
   1460  for _, line in ipairs(lines) do
   1461    -- R has # comments
   1462    if line:find('^%s*#') then
   1463      return 'r'
   1464    end
   1465    -- Rexx has /* comments */
   1466    if line:find('^%s*/%*') then
   1467      return 'rexx'
   1468    end
   1469  end
   1470 
   1471  -- Nothing recognized, use user default or assume R
   1472  if vim.g.filetype_r then
   1473    return vim.g.filetype_r
   1474  end
   1475  -- Rexx used to be the default, but R appears to be much more popular.
   1476  return 'r'
   1477 end
   1478 
   1479 --- @type vim.filetype.mapfn
   1480 function M.redif(_, bufnr)
   1481  for _, line in ipairs(getlines(bufnr, 1, 5)) do
   1482    if line:lower():find('^template%-type:') then
   1483      return 'redif'
   1484    end
   1485  end
   1486 end
   1487 
   1488 --- @type vim.filetype.mapfn
   1489 function M.reg(_, bufnr)
   1490  local line = getline(bufnr, 1):lower()
   1491  if
   1492    line:find('^regedit[0-9]*%s*$') or line:find('^windows registry editor version %d*%.%d*%s*$')
   1493  then
   1494    return 'registry'
   1495  end
   1496 end
   1497 
   1498 -- Diva (with Skill) or InstallShield
   1499 --- @type vim.filetype.mapfn
   1500 function M.rul(_, bufnr)
   1501  if table.concat(getlines(bufnr, 1, 6)):lower():find('installshield') then
   1502    return 'ishd'
   1503  end
   1504  return 'diva'
   1505 end
   1506 
   1507 local udev_rules_pattern = '^%s*udev_rules%s*=%s*"([%^"]+)/*".*'
   1508 --- @type vim.filetype.mapfn
   1509 function M.rules(path)
   1510  path = path:lower() --- @type string LuaLS bug
   1511  if
   1512    findany(path, {
   1513      '/etc/udev/.*%.rules$',
   1514      '/etc/udev/rules%.d/.*$.rules$',
   1515      '/usr/lib/udev/.*%.rules$',
   1516      '/usr/lib/udev/rules%.d/.*%.rules$',
   1517      '/lib/udev/.*%.rules$',
   1518      '/lib/udev/rules%.d/.*%.rules$',
   1519    })
   1520  then
   1521    return 'udevrules'
   1522  elseif path:find('^/etc/ufw/') then
   1523    -- Better than hog
   1524    return 'conf'
   1525  elseif findany(path, { '^/etc/polkit%-1/rules%.d', '/usr/share/polkit%-1/rules%.d' }) then
   1526    return 'javascript'
   1527  else
   1528    local ok, config_lines = pcall(fn.readfile, '/etc/udev/udev.conf')
   1529    if not ok then
   1530      return 'hog'
   1531    end
   1532    --- @cast config_lines -string
   1533    local dir = fs.dirname(path)
   1534    for _, line in ipairs(config_lines) do
   1535      local match = line:match(udev_rules_pattern)
   1536      if match then
   1537        local udev_rules = line:gsub(udev_rules_pattern, match, 1)
   1538        if dir == udev_rules then
   1539          return 'udevrules'
   1540        end
   1541      end
   1542    end
   1543    return 'hog'
   1544  end
   1545 end
   1546 
   1547 -- LambdaProlog and Standard ML signature files
   1548 --- @type vim.filetype.mapfn
   1549 function M.sig(_, bufnr)
   1550  if vim.g.filetype_sig then
   1551    return vim.g.filetype_sig
   1552  end
   1553 
   1554  local line = nextnonblank(bufnr, 1)
   1555 
   1556  -- LambdaProlog comment or keyword
   1557  if findany(line, { '^%s*/%*', '^%s*%%', '^%s*sig%s+%a' }) then
   1558    return 'lprolog'
   1559    -- SML comment or keyword
   1560  elseif findany(line, { '^%s*%(%*', '^%s*signature%s+%a', '^%s*structure%s+%a' }) then
   1561    return 'sml'
   1562  end
   1563 end
   1564 
   1565 --- @type vim.filetype.mapfn
   1566 function M.sa(_, bufnr)
   1567  local lines = table.concat(getlines(bufnr, 1, 4), '\n')
   1568  if findany(lines, { '^;', '\n;' }) then
   1569    return 'tiasm'
   1570  end
   1571  return 'sather'
   1572 end
   1573 
   1574 -- This function checks the first 25 lines of file extension "sc" to resolve
   1575 -- detection between scala and SuperCollider
   1576 --- @type vim.filetype.mapfn
   1577 function M.sc(_, bufnr)
   1578  for _, line in ipairs(getlines(bufnr, 1, 25)) do
   1579    if
   1580      findany(line, {
   1581        'var%s<',
   1582        'classvar%s<',
   1583        '%^this.*',
   1584        '|%w+|',
   1585        '%+%s%w*%s{',
   1586        '%*ar%s',
   1587      })
   1588    then
   1589      return 'supercollider'
   1590    end
   1591  end
   1592  return 'scala'
   1593 end
   1594 
   1595 -- This function checks the first line of file extension "scd" to resolve
   1596 -- detection between scdoc and SuperCollider
   1597 --- @type vim.filetype.mapfn
   1598 function M.scd(_, bufnr)
   1599  local first = '^%S+%(%d[0-9A-Za-z]*%)'
   1600  local opt = [[%s+"[^"]*"]]
   1601  local line = getline(bufnr, 1)
   1602  if findany(line, { first .. '$', first .. opt .. '$', first .. opt .. opt .. '$' }) then
   1603    return 'scdoc'
   1604  end
   1605  return 'supercollider'
   1606 end
   1607 
   1608 --- @type vim.filetype.mapfn
   1609 function M.sgml(_, bufnr)
   1610  local lines = table.concat(getlines(bufnr, 1, 5))
   1611  if lines:find('linuxdoc') then
   1612    return 'sgmllnx'
   1613  elseif lines:find('<!DOCTYPE.*DocBook') then
   1614    return 'docbk',
   1615      function(b)
   1616        vim.b[b].docbk_type = 'sgml'
   1617        vim.b[b].docbk_ver = 4
   1618      end
   1619  else
   1620    return 'sgml'
   1621  end
   1622 end
   1623 
   1624 --- @param path string
   1625 --- @param contents string[]
   1626 --- @param name? string
   1627 --- @return string?, fun(b: integer)?
   1628 local function sh(path, contents, name)
   1629  -- Path may be nil, do not fail in that case
   1630  if fn.did_filetype() ~= 0 or (path or ''):find(vim.g.ft_ignore_pat) then
   1631    -- Filetype was already detected or detection should be skipped
   1632    return
   1633  end
   1634 
   1635  -- Get the name from the first line if not specified
   1636  name = name or contents[1] or ''
   1637  if name:find('^csh$') or matchregex(name, [[^#!.\{-2,}\<csh\>]]) then
   1638    -- Some .sh scripts contain #!/bin/csh.
   1639    return M.shell(path, contents, 'csh')
   1640  elseif name:find('^tcsh$') or matchregex(name, [[^#!.\{-2,}\<tcsh\>]]) then
   1641    -- Some .sh scripts contain #!/bin/tcsh.
   1642    return M.shell(path, contents, 'tcsh')
   1643  elseif name:find('^zsh$') or matchregex(name, [[^#!.\{-2,}\<zsh\>]]) then
   1644    -- Some .sh scripts contain #!/bin/zsh.
   1645    return M.shell(path, contents, 'zsh')
   1646  end
   1647 
   1648  local on_detect --- @type fun(b: integer)?
   1649 
   1650  if name:find('^ksh$') or matchregex(name, [[^#!.\{-2,}\<ksh\>]]) then
   1651    on_detect = function(b)
   1652      vim.b[b].is_kornshell = 1
   1653      vim.b[b].is_bash = nil
   1654      vim.b[b].is_sh = nil
   1655    end
   1656  elseif
   1657    vim.g.bash_is_sh
   1658    or name:find('^bash2?$')
   1659    or matchregex(name, [[^#!.\{-2,}\<bash2\=\>]])
   1660  then
   1661    on_detect = function(b)
   1662      vim.b[b].is_bash = 1
   1663      vim.b[b].is_kornshell = nil
   1664      vim.b[b].is_sh = nil
   1665    end
   1666  elseif findany(name, { '^sh$', '^dash$' }) or matchregex(name, [[^#!.\{-2,}\<\%(da\)\=sh\>]]) then -- Ubuntu links "sh" to "dash"
   1667    on_detect = function(b)
   1668      vim.b[b].is_sh = 1
   1669      vim.b[b].is_kornshell = nil
   1670      vim.b[b].is_bash = nil
   1671    end
   1672  end
   1673  return M.shell(path, contents, 'sh'), on_detect
   1674 end
   1675 
   1676 --- @param name? string
   1677 --- @return vim.filetype.mapfn
   1678 local function sh_with(name)
   1679  return function(path, bufnr)
   1680    return sh(path, getlines(bufnr), name)
   1681  end
   1682 end
   1683 
   1684 M.sh = sh_with()
   1685 M.bash = sh_with('bash')
   1686 M.ksh = sh_with('ksh')
   1687 M.tcsh = sh_with('tcsh')
   1688 
   1689 --- For shell-like file types, check for an "exec" command hidden in a comment, as used for Tcl.
   1690 --- @param path string
   1691 --- @param contents string[]
   1692 --- @param name? string
   1693 --- @return string?
   1694 function M.shell(path, contents, name)
   1695  if fn.did_filetype() ~= 0 or matchregex(path, vim.g.ft_ignore_pat) then
   1696    -- Filetype was already detected or detection should be skipped
   1697    return
   1698  end
   1699 
   1700  local prev_line = ''
   1701  for line_nr, line in ipairs(contents) do
   1702    -- Skip the first line
   1703    if line_nr ~= 1 then
   1704      --- @type string
   1705      line = line:lower()
   1706      if line:find('%s*exec%s') and not prev_line:find('^%s*#.*\\$') then
   1707        -- Found an "exec" line after a comment with continuation
   1708        local n = line:gsub('%s*exec%s+([^ ]*/)?', '', 1)
   1709        if matchregex(n, [[\c\<tclsh\|\<wish]]) then
   1710          return 'tcl'
   1711        end
   1712      end
   1713      prev_line = line
   1714    end
   1715  end
   1716  return name
   1717 end
   1718 
   1719 -- Swift Intermediate Language or SILE
   1720 --- @type vim.filetype.mapfn
   1721 function M.sil(_, bufnr)
   1722  for _, line in ipairs(getlines(bufnr, 1, 100)) do
   1723    if line:find('^%s*[\\%%]') then
   1724      return 'sile'
   1725    elseif line:find('^%s*%S') then
   1726      return 'sil'
   1727    end
   1728  end
   1729  -- No clue, default to "sil"
   1730  return 'sil'
   1731 end
   1732 
   1733 -- SMIL or SNMP MIB file
   1734 --- @type vim.filetype.mapfn
   1735 function M.smi(_, bufnr)
   1736  local line = getline(bufnr, 1)
   1737  if matchregex(line, [[\c\<smil\>]]) then
   1738    return 'smil'
   1739  else
   1740    return 'mib'
   1741  end
   1742 end
   1743 
   1744 --- @type vim.filetype.mapfn
   1745 function M.sql(_, _)
   1746  return vim.g.filetype_sql and vim.g.filetype_sql or 'sql'
   1747 end
   1748 
   1749 -- Determine if a *.src file is Kuka Robot Language
   1750 --- @type vim.filetype.mapfn
   1751 function M.src(_, bufnr)
   1752  if vim.g.filetype_src then
   1753    return vim.g.filetype_src
   1754  end
   1755  local line = nextnonblank(bufnr, 1)
   1756  if matchregex(line, [[\c\v^\s*%(\&\w+|%(global\s+)?def%(fct)?>)]]) then
   1757    return 'krl'
   1758  end
   1759 end
   1760 
   1761 --- @type vim.filetype.mapfn
   1762 function M.sys(_, bufnr)
   1763  if vim.g.filetype_sys then
   1764    return vim.g.filetype_sys
   1765  elseif is_rapid(bufnr) then
   1766    return 'rapid'
   1767  end
   1768  return 'bat'
   1769 end
   1770 
   1771 -- Choose context, plaintex, or tex (LaTeX) based on these rules:
   1772 -- 1. Check the first line of the file for "%&<format>".
   1773 -- 2. Check the first 1000 non-comment lines for LaTeX or ConTeXt keywords.
   1774 -- 3. Default to "plain" or to g:tex_flavor, can be set in user's vimrc.
   1775 --- @type vim.filetype.mapfn
   1776 function M.tex(path, bufnr)
   1777  local matched, _, format = getline(bufnr, 1):find('^%%&%s*(%a+)')
   1778  if matched and format then
   1779    --- @type string
   1780    format = format:lower():gsub('pdf', '', 1)
   1781  elseif path:lower():find('tex/context/.*/.*%.tex') then
   1782    return 'context'
   1783  else
   1784    -- Default value, may be changed later:
   1785    format = vim.g.tex_flavor or 'plaintex'
   1786 
   1787    local lpat = [[documentclass\>\|usepackage\>\|begin{\|newcommand\>\|renewcommand\>]]
   1788    local cpat =
   1789      [[start\a\+\|setup\a\+\|usemodule\|enablemode\|enableregime\|setvariables\|useencoding\|usesymbols\|stelle\a\+\|verwende\a\+\|stel\a\+\|gebruik\a\+\|usa\a\+\|imposta\a\+\|regle\a\+\|utilisemodule\>]]
   1790 
   1791    for i, l in ipairs(getlines(bufnr, 1, 1000)) do
   1792      -- Find first non-comment line
   1793      if not l:find('^%s*%%%S') then
   1794        -- Check the next thousand lines for a LaTeX or ConTeXt keyword.
   1795        for _, line in ipairs(getlines(bufnr, i, i + 1000)) do
   1796          if matchregex(line, [[\c^\s*\\\%(]] .. lpat .. [[\)]]) then
   1797            return 'tex'
   1798          elseif matchregex(line, [[\c^\s*\\\%(]] .. cpat .. [[\)]]) then
   1799            return 'context'
   1800          end
   1801        end
   1802      end
   1803    end
   1804  end -- if matched
   1805 
   1806  -- Translation from formats to file types.  TODO:  add AMSTeX, RevTex, others?
   1807  if format == 'plain' then
   1808    return 'plaintex'
   1809  elseif format == 'plaintex' or format == 'context' then
   1810    return format
   1811  else
   1812    -- Probably LaTeX
   1813    return 'tex'
   1814  end
   1815 end
   1816 
   1817 -- Determine if a *.tf file is TF (TinyFugue) mud client or terraform
   1818 --- @type vim.filetype.mapfn
   1819 function M.tf(_, bufnr)
   1820  for _, line in ipairs(getlines(bufnr)) do
   1821    -- Assume terraform file on a non-empty line (not whitespace-only)
   1822    -- and when the first non-whitespace character is not a ; or /
   1823    if not line:find('^%s*$') and not line:find('^%s*[;/]') then
   1824      return 'terraform'
   1825    end
   1826  end
   1827  return 'tf'
   1828 end
   1829 
   1830 --- @type vim.filetype.mapfn
   1831 function M.ttl(_, bufnr)
   1832  local line = getline(bufnr, 1):lower()
   1833  if line:find('^@?prefix') or line:find('^@?base') then
   1834    return 'turtle'
   1835  end
   1836  return 'teraterm'
   1837 end
   1838 
   1839 --- @type vim.filetype.mapfn
   1840 function M.txt(_, bufnr)
   1841  -- helpfiles match *.txt, but should have a modeline as last line
   1842  if not getline(bufnr, -1):find('vim:.*ft=help') then
   1843    return 'text'
   1844  end
   1845 end
   1846 
   1847 --- @type vim.filetype.mapfn
   1848 function M.typ(_, bufnr)
   1849  if vim.g.filetype_typ then
   1850    return vim.g.filetype_typ
   1851  end
   1852 
   1853  for _, line in ipairs(getlines(bufnr, 1, 200)) do
   1854    if
   1855      findany(line, {
   1856        '^CASE[%s]?=[%s]?SAME$',
   1857        '^CASE[%s]?=[%s]?LOWER$',
   1858        '^CASE[%s]?=[%s]?UPPER$',
   1859        '^CASE[%s]?=[%s]?OPPOSITE$',
   1860        '^TYPE%s',
   1861      })
   1862    then
   1863      return 'sql'
   1864    end
   1865  end
   1866 
   1867  return 'typst'
   1868 end
   1869 
   1870 --- @type vim.filetype.mapfn
   1871 function M.uci(_, bufnr)
   1872  -- Return "uci" iff the file has a config or package statement near the
   1873  -- top of the file and all preceding lines were comments or blank.
   1874  for _, line in ipairs(getlines(bufnr, 1, 3)) do
   1875    -- Match a config or package statement at the start of the line.
   1876    if
   1877      line:find('^%s*[cp]%s+%S')
   1878      or line:find('^%s*config%s+%S')
   1879      or line:find('^%s*package%s+%S')
   1880    then
   1881      return 'uci'
   1882    end
   1883    -- Match a line that is either all blank or blank followed by a comment
   1884    if not (line:find('^%s*$') or line:find('^%s*#')) then
   1885      break
   1886    end
   1887  end
   1888 end
   1889 
   1890 -- Determine if a .v file is Verilog, V, or Coq
   1891 --- @type vim.filetype.mapfn
   1892 function M.v(_, bufnr)
   1893  if fn.did_filetype() ~= 0 then
   1894    -- Filetype was already detected
   1895    return
   1896  end
   1897  if vim.g.filetype_v then
   1898    return vim.g.filetype_v
   1899  end
   1900  local in_comment = 0
   1901  for _, line in ipairs(getlines(bufnr, 1, 500)) do
   1902    if line:find('^%s*/%*') then
   1903      in_comment = 1
   1904    end
   1905    if in_comment == 1 then
   1906      if line:find('%*/') then
   1907        in_comment = 0
   1908      end
   1909    elseif not line:find('^%s*//') then
   1910      if
   1911        line:find('%.%s*$') and not line:find('/[/*]')
   1912        or line:find('%(%*') and not line:find('/[/*].*%(%*')
   1913      then
   1914        return 'coq'
   1915      elseif findany(line, { ';%s*$', ';%s*/[/*]', '^%s*module%s+%w+%s*%(' }) then
   1916        return 'verilog'
   1917      end
   1918    end
   1919  end
   1920  return 'v'
   1921 end
   1922 
   1923 --- @type vim.filetype.mapfn
   1924 function M.vba(_, bufnr)
   1925  if getline(bufnr, 1):find('^["#] Vimball Archiver') then
   1926    return 'vim'
   1927  end
   1928  return 'vb'
   1929 end
   1930 
   1931 -- WEB (*.web is also used for Winbatch: Guess, based on expecting "%" comment
   1932 -- lines in a WEB file).
   1933 --- @type vim.filetype.mapfn
   1934 function M.web(_, bufnr)
   1935  for _, line in ipairs(getlines(bufnr, 1, 5)) do
   1936    if line:find('^%%') then
   1937      return 'web'
   1938    end
   1939  end
   1940  return 'winbatch'
   1941 end
   1942 
   1943 -- XFree86 config
   1944 --- @type vim.filetype.mapfn
   1945 function M.xfree86_v3(_, _)
   1946  return 'xf86conf',
   1947    function(bufnr)
   1948      local line = getline(bufnr, 1)
   1949      if matchregex(line, [[\<XConfigurator\>]]) then
   1950        vim.b[bufnr].xf86conf_xfree86_version = 3
   1951      end
   1952    end
   1953 end
   1954 
   1955 -- XFree86 config
   1956 --- @type vim.filetype.mapfn
   1957 function M.xfree86_v4(_, _)
   1958  return 'xf86conf', function(b)
   1959    vim.b[b].xf86conf_xfree86_version = 4
   1960  end
   1961 end
   1962 
   1963 --- @type vim.filetype.mapfn
   1964 function M.xml(_, bufnr)
   1965  for _, line in ipairs(getlines(bufnr, 1, 100)) do
   1966    local is_docbook4 = line:find('<!DOCTYPE.*DocBook')
   1967    line = line:lower()
   1968    local is_docbook5 = line:find([[ xmlns="http://docbook.org/ns/docbook"]])
   1969    if is_docbook4 or is_docbook5 then
   1970      return 'docbk',
   1971        function(b)
   1972          vim.b[b].docbk_type = 'xml'
   1973          vim.b[b].docbk_ver = is_docbook4 and 4 or 5
   1974        end
   1975    end
   1976    if line:find([[xmlns:xbl="http://www.mozilla.org/xbl"]]) then
   1977      return 'xbl'
   1978    end
   1979  end
   1980  return 'xml'
   1981 end
   1982 
   1983 --- @type vim.filetype.mapfn
   1984 function M.y(_, bufnr)
   1985  for _, line in ipairs(getlines(bufnr, 1, 100)) do
   1986    if line:find('^%s*%%') then
   1987      return 'yacc'
   1988    end
   1989    if matchregex(line, [[\c^\s*\(#\|class\>\)]]) and not line:lower():find('^%s*#%s*include') then
   1990      return 'racc'
   1991    end
   1992  end
   1993  return 'yacc'
   1994 end
   1995 
   1996 -- luacheck: pop
   1997 -- luacheck: pop
   1998 
   1999 local patterns_hashbang = {
   2000  ['^zsh\\>'] = { 'zsh', { vim_regex = true } },
   2001  ['^\\(tclsh\\|wish\\|expectk\\|itclsh\\|itkwish\\)\\>'] = { 'tcl', { vim_regex = true } },
   2002  ['^expect\\>'] = { 'expect', { vim_regex = true } },
   2003  ['^gnuplot\\>'] = { 'gnuplot', { vim_regex = true } },
   2004  ['make\\>'] = { 'make', { vim_regex = true } },
   2005  ['^pike\\%(\\>\\|[0-9]\\)'] = { 'pike', { vim_regex = true } },
   2006  lua = 'lua',
   2007  perl = 'perl',
   2008  php = 'php',
   2009  python = 'python',
   2010  ['^groovy\\>'] = { 'groovy', { vim_regex = true } },
   2011  raku = 'raku',
   2012  ruby = 'ruby',
   2013  ['node\\(js\\)\\=\\>\\|js\\>'] = { 'javascript', { vim_regex = true } },
   2014  ['rhino\\>'] = { 'javascript', { vim_regex = true } },
   2015  just = 'just',
   2016  -- BC calculator
   2017  ['^bc\\>'] = { 'bc', { vim_regex = true } },
   2018  ['sed\\>'] = { 'sed', { vim_regex = true } },
   2019  ocaml = 'ocaml',
   2020  -- Awk scripts; also finds "gawk"
   2021  ['awk\\>'] = { 'awk', { vim_regex = true } },
   2022  wml = 'wml',
   2023  scheme = 'scheme',
   2024  cfengine = 'cfengine',
   2025  escript = 'erlang',
   2026  haskell = 'haskell',
   2027  clojure = 'clojure',
   2028  ['scala\\>'] = { 'scala', { vim_regex = true } },
   2029  -- Free Pascal
   2030  ['instantfpc\\>'] = { 'pascal', { vim_regex = true } },
   2031  ['fennel\\>'] = { 'fennel', { vim_regex = true } },
   2032  -- MikroTik RouterOS script
   2033  ['rsc\\>'] = { 'routeros', { vim_regex = true } },
   2034  ['fish\\>'] = { 'fish', { vim_regex = true } },
   2035  ['gforth\\>'] = { 'forth', { vim_regex = true } },
   2036  ['icon\\>'] = { 'icon', { vim_regex = true } },
   2037  guile = 'scheme',
   2038  ['nix%-shell'] = 'nix',
   2039  ['^crystal\\>'] = { 'crystal', { vim_regex = true } },
   2040  ['^\\%(rexx\\|regina\\)\\>'] = { 'rexx', { vim_regex = true } },
   2041  ['^janet\\>'] = { 'janet', { vim_regex = true } },
   2042  ['^dart\\>'] = { 'dart', { vim_regex = true } },
   2043  ['^execlineb\\>'] = { 'execline', { vim_regex = true } },
   2044  ['^bpftrace\\>'] = { 'bpftrace', { vim_regex = true } },
   2045  ['^vim\\>'] = { 'vim', { vim_regex = true } },
   2046 }
   2047 
   2048 --- File starts with "#!".
   2049 --- @param contents string[]
   2050 --- @param path string
   2051 --- @param dispatch_extension fun(name: string): string?, fun(b: integer)?
   2052 --- @return string?
   2053 --- @return fun(b: integer)?
   2054 local function match_from_hashbang(contents, path, dispatch_extension)
   2055  local first_line = assert(contents[1])
   2056  -- Check for a line like "#!/usr/bin/env {options} bash".  Turn it into
   2057  -- "#!/usr/bin/bash" to make matching easier.
   2058  -- Recognize only a few {options} that are commonly used.
   2059  if matchregex(first_line, [[^#!\s*\S*\<env\s]]) then
   2060    first_line = fn.substitute(first_line, [[\s\zs--split-string\(\s\|=\)]], '', '')
   2061    first_line = fn.substitute(first_line, [[\s\zs[A-Za-z0-9_]\+=\S*\ze\s]], '', 'g')
   2062    first_line =
   2063      fn.substitute(first_line, [[\s\zs\%(-[iS]\+\|--ignore-environment\)\ze\s]], '', 'g')
   2064    first_line = fn.substitute(first_line, [[\<env\s\+]], '', '')
   2065  end
   2066 
   2067  -- Get the program name.
   2068  -- Only accept spaces in PC style paths: "#!c:/program files/perl [args]".
   2069  -- If the word env is used, use the first word after the space:
   2070  -- "#!/usr/bin/env perl [path/args]"
   2071  -- If there is no path use the first word: "#!perl [path/args]".
   2072  -- Otherwise get the last word after a slash: "#!/usr/bin/perl [path/args]".
   2073  local name --- @type string
   2074  if first_line:find('^#!%s*%a:[/\\]') then
   2075    name = fn.substitute(first_line, [[^#!.*[/\\]\(\i\+\).*]], '\\1', '')
   2076  elseif matchregex(first_line, [[^#!.*\<env\>]]) then
   2077    name = fn.substitute(first_line, [[^#!.*\<env\>\s\+\(\i\+\).*]], '\\1', '')
   2078  elseif matchregex(first_line, [[^#!\s*[^/\\ ]*\>\([^/\\]\|$\)]]) then
   2079    name = fn.substitute(first_line, [[^#!\s*\([^/\\ ]*\>\).*]], '\\1', '')
   2080  else
   2081    name = fn.substitute(first_line, [[^#!\s*\S*[/\\]\(\f\+\).*]], '\\1', '')
   2082  end
   2083 
   2084  -- tcl scripts may have #!/bin/sh in the first line and "exec wish" in the
   2085  -- third line. Suggested by Steven Atkinson.
   2086  if contents[3] and contents[3]:find('^exec wish') then
   2087    name = 'wish'
   2088  end
   2089 
   2090  if matchregex(name, [[^\(bash\d*\|dash\|ksh\d*\|sh\)\>]]) then
   2091    -- Bourne-like shell scripts: bash bash2 dash ksh ksh93 sh
   2092    return sh(path, contents, first_line)
   2093  elseif matchregex(name, [[^csh\>]]) then
   2094    return M.shell(path, contents, vim.g.filetype_csh or 'csh')
   2095  elseif matchregex(name, [[^tcsh\>]]) then
   2096    return M.shell(path, contents, 'tcsh')
   2097  end
   2098 
   2099  for k, v in pairs(patterns_hashbang) do
   2100    local ft = type(v) == 'table' and v[1] or v --[[@as string]]
   2101    local opts = type(v) == 'table' and v[2] or {}
   2102    if opts.vim_regex and matchregex(name, k) or name:find(k) then
   2103      return ft
   2104    end
   2105  end
   2106 
   2107  -- If nothing matched, check the extension table. For a hashbang like
   2108  -- '#!/bin/env foo', this will set the filetype to 'fooscript' assuming
   2109  -- the filetype for the 'foo' extension is 'fooscript' in the extension table.
   2110  return dispatch_extension(name)
   2111 end
   2112 
   2113 --- @class vim.filetype.detect.PatternOpts
   2114 --- @field vim_regex? true? use Vim regexes instead of Lua patterns.
   2115 --- @field start_lnum? integer? Start line number for matching, defaults to 1.
   2116 --- @field end_lnum? integer? End line number for matching, defaults to -1 (last line).
   2117 --- @field ignore_case? true ignore case when matching.
   2118 
   2119 -- TODO(lewis6991): split this table into two tables, one for patterns and one for functions.
   2120 local patterns_text = {
   2121  ['^#compdef\\>'] = { 'zsh', { vim_regex = true } },
   2122  ['^#autoload\\>'] = { 'zsh', { vim_regex = true } },
   2123  -- ELM Mail files
   2124  ['^From [a-zA-Z][a-zA-Z_0-9%.=%-]*(@[^ ]*)? .* 19%d%d$'] = 'mail',
   2125  ['^From [a-zA-Z][a-zA-Z_0-9%.=%-]*(@[^ ]*)? .* 20%d%d$'] = 'mail',
   2126  ['^From %- .* 19%d%d$'] = 'mail',
   2127  ['^From %- .* 20%d%d$'] = 'mail',
   2128  ['^[Rr][Ee][Tt][Uu][Rr][Nn]%-[Pp][Aa][Tt][Hh]:%s<.*@.*>$'] = 'mail',
   2129  -- Mason
   2130  ['^<[%%&].*>'] = 'mason',
   2131  -- Vim scripts (must have '" vim' as the first line to trigger this)
   2132  ['^" *[vV]im$['] = 'vim',
   2133  -- libcxx and libstdc++ standard library headers like ["iostream["] do not have
   2134  -- an extension, recognize the Emacs file mode.
   2135  ['%-%*%-.*[cC]%+%+.*%-%*%-'] = 'cpp',
   2136  ['^\\*\\* LambdaMOO Database, Format Version \\%([1-3]\\>\\)\\@!\\d\\+ \\*\\*$'] = {
   2137    'moo',
   2138    { vim_regex = true },
   2139  },
   2140  -- Diff file:
   2141  -- - "diff" in first line (context diff)
   2142  -- - "Only in " in first line
   2143  -- - "34,35c34,35" normal diff format output
   2144  -- - "--- " in first line and "+++ " in second line (unified diff).
   2145  -- - "*** " in first line and "--- " in second line (context diff).
   2146  -- - "# It was generated by makepatch " in the second line (makepatch diff).
   2147  -- - "Index: <filename>" in the first line (CVS file)
   2148  -- - "=== ", line of "=", "---", "+++ " (SVK diff)
   2149  -- - "=== ", "--- ", "+++ " (bzr diff, common case)
   2150  -- - "=== (removed|added|renamed|modified)" (bzr diff, alternative)
   2151  -- - "# HG changeset patch" in first line (Mercurial export format)
   2152  ['^\\(diff\\>\\|Only in \\|\\d\\+\\(,\\d\\+\\)\\=[cda]\\d\\+\\(,\\d\\+\\)\\=\\>$\\|# It was generated by makepatch \\|Index:\\s\\+\\f\\+\\r\\=$\\|===== \\f\\+ \\d\\+\\.\\d\\+ vs edited\\|==== //\\f\\+#\\d\\+\\|# HG changeset patch\\)'] = {
   2153    'diff',
   2154    { vim_regex = true },
   2155  },
   2156  function(contents)
   2157    return diff(contents)
   2158  end,
   2159  -- PostScript Files (must have %!PS as the first line, like a2ps output)
   2160  ['^%%![ \t]*PS'] = 'postscr',
   2161  function(contents)
   2162    return m4(contents)
   2163  end,
   2164  -- SiCAD scripts (must have procn or procd as the first line to trigger this)
   2165  ['^ *proc[nd] *$'] = { 'sicad', { ignore_case = true } },
   2166  ['^%*%*%*%*  Purify'] = 'purifylog',
   2167  -- XML
   2168  ['<%?%s*xml.*%?>'] = 'xml',
   2169  -- XHTML (e.g.: PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN")
   2170  ['\\<DTD\\s\\+XHTML\\s'] = 'xhtml',
   2171  -- HTML (e.g.: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN")
   2172  -- Avoid "doctype html", used by slim.
   2173  ['\\c<!DOCTYPE\\s\\+html\\>'] = { 'html', { vim_regex = true } },
   2174  -- PDF
   2175  ['^%%PDF%-'] = 'pdf',
   2176  -- XXD output
   2177  ['^%x%x%x%x%x%x%x: %x%x ?%x%x ?%x%x ?%x%x '] = 'xxd',
   2178  -- RCS/CVS log output
   2179  ['^RCS file:'] = { 'rcslog', { start_lnum = 1, end_lnum = 2 } },
   2180  -- CVS commit
   2181  ['^CVS:'] = { 'cvs', { start_lnum = 2 } },
   2182  ['^CVS: '] = { 'cvs', { start_lnum = -1 } },
   2183  -- Prescribe
   2184  ['^!R!'] = 'prescribe',
   2185  -- Send-pr
   2186  ['^SEND%-PR:'] = 'sendpr',
   2187  -- SNNS files
   2188  ['^SNNS network definition file'] = 'snnsnet',
   2189  ['^SNNS pattern definition file'] = 'snnspat',
   2190  ['^SNNS result file'] = 'snnsres',
   2191  ['^%%.-[Vv]irata'] = { 'virata', { start_lnum = 1, end_lnum = 5 } },
   2192  function(lines)
   2193    if
   2194      -- inaccurate fast match first, then use accurate slow match
   2195      (lines[1]:find('execve%(') and lines[1]:find('^[0-9:%. ]*execve%('))
   2196      or lines[1]:find('^__libc_start_main')
   2197    then
   2198      return 'strace'
   2199    end
   2200  end,
   2201  -- VSE JCL
   2202  ['^\\* $$ JOB\\>'] = { 'vsejcl', { vim_regex = true } },
   2203  ['^// *JOB\\>'] = { 'vsejcl', { vim_regex = true } },
   2204  -- TAK and SINDA
   2205  ['K & K  Associates'] = { 'takout', { start_lnum = 4 } },
   2206  ['TAK 2000'] = { 'takout', { start_lnum = 2 } },
   2207  ['S Y S T E M S   I M P R O V E D '] = { 'syndaout', { start_lnum = 3 } },
   2208  ['Run Date: '] = { 'takcmp', { start_lnum = 6 } },
   2209  ['Node    File  1'] = { 'sindacmp', { start_lnum = 9 } },
   2210  dns_zone,
   2211  -- Valgrind
   2212  ['^==%d+== valgrind'] = 'valgrind',
   2213  ['^==%d+== Using valgrind'] = { 'valgrind', { start_lnum = 3 } },
   2214  -- Go docs
   2215  ['PACKAGE DOCUMENTATION$'] = 'godoc',
   2216  -- Renderman Interface Bytestream
   2217  ['^##RenderMan'] = 'rib',
   2218  -- Scheme scripts
   2219  ['exec%s%+%S*scheme'] = { 'scheme', { start_lnum = 1, end_lnum = 2 } },
   2220  -- Git output
   2221  ['^\\(commit\\|tree\\|object\\) \\x\\{40,\\}\\>\\|^tag \\S\\+$'] = {
   2222    'git',
   2223    { vim_regex = true },
   2224  },
   2225  function(lines)
   2226    -- Gprof (gnu profiler)
   2227    if
   2228      lines[1] == 'Flat profile:'
   2229      and lines[2] == ''
   2230      and lines[3]:find('^Each sample counts as .* seconds%.$')
   2231    then
   2232      return 'gprof'
   2233    end
   2234  end,
   2235  -- Erlang terms
   2236  -- (See also: http://www.gnu.org/software/emacs/manual/html_node/emacs/Choosing-Modes.html#Choosing-Modes)
   2237  ['%-%*%-.*erlang.*%-%*%-'] = { 'erlang', { ignore_case = true } },
   2238  -- YAML
   2239  ['^%%YAML'] = 'yaml',
   2240  -- MikroTik RouterOS script
   2241  ['^#.*by RouterOS'] = 'routeros',
   2242  -- Sed scripts
   2243  -- #ncomment is allowed but most likely a false positive so require a space before any trailing comment text
   2244  ['^#n%s'] = 'sed',
   2245  ['^#n$'] = 'sed',
   2246  ['^#%s+Reconstructed via infocmp from file:'] = 'terminfo',
   2247  ['^File: .*%.info,  Node: .*,  Next: .*,  Up: '] = 'info',
   2248  ['^File: .*%.info,  Node: .*,  Prev: .*,  Up: '] = 'info',
   2249  ['This is the top of the INFO tree.'] = 'info',
   2250 }
   2251 
   2252 --- File does not start with "#!".
   2253 --- @param contents string[]
   2254 --- @param path string
   2255 --- @return string?
   2256 --- @return fun(b: integer)?
   2257 local function match_from_text(contents, path)
   2258  if assert(contents[1]):find('^:$') then
   2259    -- Bourne-like shell scripts: sh ksh bash bash2
   2260    return sh(path, contents)
   2261  elseif
   2262    matchregex(
   2263      '\n' .. table.concat(contents, '\n'),
   2264      [[\n\s*emulate\s\+\%(-[LR]\s\+\)\=[ckz]\=sh\>]]
   2265    )
   2266  then
   2267    -- Z shell scripts
   2268    return 'zsh'
   2269  end
   2270 
   2271  for k, v in pairs(patterns_text) do
   2272    if type(v) == 'string' then
   2273      -- Check the first line only
   2274      if assert(contents[1]):find(k) then
   2275        return v
   2276      end
   2277    elseif type(v) == 'function' then
   2278      -- If filetype detection fails, continue with the next pattern
   2279      local ok, ft = pcall(v, contents)
   2280      if ok and ft then
   2281        return ft
   2282      end
   2283    else
   2284      --- @cast k string
   2285      local opts = type(v) == 'table' and v[2] or {}
   2286      --- @cast opts vim.filetype.detect.PatternOpts
   2287      if opts.start_lnum and opts.end_lnum then
   2288        assert(
   2289          not opts.ignore_case,
   2290          'ignore_case=true is ignored when start_lnum is also present, needs refactor'
   2291        )
   2292        for i = opts.start_lnum, opts.end_lnum do
   2293          local line = contents[i]
   2294          if not line then
   2295            break
   2296          elseif line:find(k) then
   2297            return v[1]
   2298          end
   2299        end
   2300      else
   2301        local line_nr = opts.start_lnum == -1 and #contents or opts.start_lnum or 1
   2302        local contents_line_nr = contents[line_nr]
   2303        if contents_line_nr then
   2304          local line = opts.ignore_case and contents_line_nr:lower() or contents_line_nr
   2305          if opts.vim_regex and matchregex(line, k) or line:find(k) then
   2306            return v[1]
   2307          end
   2308        end
   2309      end
   2310    end
   2311  end
   2312  return cvs_diff(path, contents)
   2313 end
   2314 
   2315 --- @param contents string[]
   2316 --- @param path string
   2317 --- @param dispatch_extension fun(name: string): string?, fun(b: integer)?
   2318 --- @return string?
   2319 --- @return fun(b: integer)?
   2320 function M.match_contents(contents, path, dispatch_extension)
   2321  local first_line = assert(contents[1])
   2322  if first_line:find('^#!') then
   2323    return match_from_hashbang(contents, path, dispatch_extension)
   2324  else
   2325    return match_from_text(contents, path)
   2326  end
   2327 end
   2328 
   2329 return M