_headings.lua 3.8 KB

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