neovim

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

_headings.lua (4050B)


      1 local ts = vim.treesitter
      2 local api = vim.api
      3 
      4 --- Treesitter-based navigation functions for headings
      5 local M = {}
      6 
      7 -- TODO(clason): use runtimepath queries (for other languages)
      8 local heading_queries = {
      9  vimdoc = [[
     10    (h1 (heading) @h1)
     11    (h2 (heading) @h2)
     12    (h3 (heading) @h3)
     13    (column_heading (heading) @h4)
     14  ]],
     15  markdown = [[
     16    (setext_heading
     17      heading_content: (_) @h1
     18      (setext_h1_underline))
     19    (setext_heading
     20      heading_content: (_) @h2
     21      (setext_h2_underline))
     22    (atx_heading
     23      (atx_h1_marker)
     24      heading_content: (_) @h1)
     25    (atx_heading
     26      (atx_h2_marker)
     27      heading_content: (_) @h2)
     28    (atx_heading
     29      (atx_h3_marker)
     30      heading_content: (_) @h3)
     31    (atx_heading
     32      (atx_h4_marker)
     33      heading_content: (_) @h4)
     34    (atx_heading
     35      (atx_h5_marker)
     36      heading_content: (_) @h5)
     37    (atx_heading
     38      (atx_h6_marker)
     39      heading_content: (_) @h6)
     40  ]],
     41 }
     42 
     43 ---@class TS.Heading
     44 ---@field bufnr integer
     45 ---@field lnum integer
     46 ---@field text string
     47 ---@field level integer
     48 
     49 --- Extract headings from buffer
     50 --- @param bufnr integer buffer to extract headings from
     51 --- @return TS.Heading[]
     52 local get_headings = function(bufnr)
     53  local lang = ts.language.get_lang(vim.bo[bufnr].filetype)
     54  if not lang then
     55    return {}
     56  end
     57  local parser = assert(ts.get_parser(bufnr, lang, { error = false }))
     58  local query = ts.query.parse(lang, heading_queries[lang])
     59  local root = parser:parse()[1]:root()
     60  local headings = {}
     61  for id, node, _, _ in query:iter_captures(root, bufnr) do
     62    local text = ts.get_node_text(node, bufnr)
     63    local row, col = node:start()
     64    --- why can't you just be normal?!
     65    local skip ---@type boolean|integer
     66    if lang == 'vimdoc' then
     67      -- only column_headings at col 1 are headings, otherwise it's code examples
     68      skip = (id == 4 and col > 0)
     69        -- ignore tabular material
     70        or (id == 4 and (text:find('\t') or text:find('  ')))
     71        -- ignore tag-only headings
     72        or (node:child_count() == 1 and node:child(0):type() == 'tag')
     73    end
     74    if not skip then
     75      table.insert(headings, {
     76        bufnr = bufnr,
     77        lnum = row + 1,
     78        text = text,
     79        level = id,
     80      })
     81    end
     82  end
     83  return headings
     84 end
     85 
     86 --- @param qf_height? integer height of loclist window
     87 --- Shows an Outline (table of contents) of the current buffer, in the loclist.
     88 function M.show_toc(qf_height)
     89  local bufnr = api.nvim_get_current_buf()
     90  local bufname = api.nvim_buf_get_name(bufnr)
     91  local headings = get_headings(bufnr)
     92  if #headings == 0 then
     93    return
     94  end
     95  -- add indentation for nicer list formatting
     96  for _, heading in pairs(headings) do
     97    -- Quickfix trims whitespace, so use non-breaking space instead
     98    heading.text = ('\194\160'):rep((heading.level - 1) * 2) .. heading.text
     99  end
    100  vim.fn.setloclist(0, headings, ' ')
    101  vim.fn.setloclist(0, {}, 'a', { title = 'Table of contents' })
    102  vim.cmd.lopen(qf_height)
    103  vim.w.qf_toc = bufname
    104  -- reload syntax file after setting qf_toc variable
    105  vim.bo.filetype = 'qf'
    106 end
    107 
    108 --- Jump to section
    109 --- @param opts table jump options
    110 ---  - count integer direction to jump (>0 forward, <0 backward)
    111 ---  - level integer only consider headings up to level
    112 --- todo(clason): support count
    113 function M.jump(opts)
    114  local bufnr = api.nvim_get_current_buf()
    115  local headings = get_headings(bufnr)
    116  if #headings == 0 then
    117    return
    118  end
    119 
    120  local winid = api.nvim_get_current_win()
    121  local curpos = vim.fn.getcurpos(winid)[2] --[[@as integer]]
    122  local maxlevel = opts.level or 6
    123 
    124  if opts.count > 0 then
    125    for _, heading in ipairs(headings) do
    126      if heading.lnum > curpos and heading.level <= maxlevel then
    127        api.nvim_win_set_cursor(winid, { heading.lnum, 0 })
    128        return
    129      end
    130    end
    131  elseif opts.count < 0 then
    132    for i = #headings, 1, -1 do
    133      if headings[i].lnum < curpos and headings[i].level <= maxlevel then
    134        api.nvim_win_set_cursor(winid, { headings[i].lnum, 0 })
    135        return
    136      end
    137    end
    138  end
    139 end
    140 
    141 return M