_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