MAJOR: Complete rewrite of outline parsing into buffer lines

Scope:
- Parsing of symbol tree
- Producing the flattened tree
- Producing the lines shown in the outline based on the symbol tree
- API of exported functions for parser.lua and writer.lua

Note that the formatting of the outline remains the same as before.

Fixes:
- Guide highlights sometimes cover fold marker areas (may be related to
  the issue brought up by @silvercircle on reddit)
- Guide highlights do not work when using guide markers of different
  widths than the default (such as setting all markers to ascii chars)

All of these issues are now fixed after integrating the a parser
algorithm.

This commit introduces:
1. A better algorithm for flattening & parsing the tree in one go
2. `OutlineFoldMarker` highlight group
3. Fixed inconsistent highlighting of guides and legacy (somewhat weaker
   code), through (1).
4. Minor performance improvements
5. Type hints for the symbol tree
6. Removed several functions from writer.lua and parser.lua due to them
   being merged into writer.make_outline

This can be seen as a breaking change because functions that were
exported had altered behaviours. However I doubt these functions
actually have any critical use outside of this plugin, hence it isn't
really a breaking change as the user-experience remains the same.

The extraneous left padding on the outline window is now a relic of the
past 🎉

The old implementation, parser.get_lines used a flattened tree and was
inefficient, full of off-by-one corrections.

While trying to look for bug fixes in that function I realized it's the
sort of "if it works, don't touch it" portion of code.

Hence, I figured a complete rewrite is necessary.

Now, the function responsible for making the outline lines lives at
writer.make_outline. Building the flattened tree, getting lines, details
and linenos are now done in one go.

This is a tradeoff between modular design and efficiency.

parser.lua still serve important purposes:
- local parse_result function converts the hierarchical tables from
  provider into a nested form tree, used everywhere in outline.nvim. The
  type hint of the return value is now defined -- outline.SymbolNode
- preorder_iter is an iterator that traverses the aforementioned tree in
  pre-order style. First the parents, all the childrens, and so on until
  the last node of the root tree. This is used in writer.make_outline to
  abstract a way the traversal code from the code of making the lines.

Thanks to stack overflow I did not have to consult a DS book to figure
out the cleanest way of this traversal method without using recursion.

This, of course, closes #14 on github.

Note that another minor 'breaking' change is that previously, hl for the
guides where grouped per-character, now they are grouped together for
all the guide markers in the same line. This should not be a problem for
those who only style the fg color for guide hl. However, if you're
styling the bg color, they will now take on that bg collectively rather
than individually.

This change eliminates future maintenance burden because controlling
per-character guide highlights requires careful avoidance of off-by-one
errors.

I have tested most common features to work as before.
I may have missed particular edge cases.

Please take note of "scope" at the top of this commit message.
This commit is contained in:
hedy
2023-11-13 14:19:43 +08:00
parent bc2b0ff9ac
commit 66aecc7636
9 changed files with 323 additions and 265 deletions

View File

@@ -563,6 +563,11 @@ require'outline'
- You can customize the split command used for creating the outline window split - You can customize the split command used for creating the outline window split
using `outline_window.split_command`, such as `"topleft vsp"`. See `:h windows` using `outline_window.split_command`, such as `"topleft vsp"`. See `:h windows`
- Is the outline window too slow when first opening a file? This is usually due
to the LSP not being ready when you open outline, hence we have to wait for the
LSP response before the outline can be shown. If LSP is ready generally the
outline latency is almost negligible.
## Recipes ## Recipes
Behaviour you may want to achieve and the combination of configuration options Behaviour you may want to achieve and the combination of configuration options

View File

@@ -2,6 +2,8 @@ local vim = vim
local M = {} local M = {}
-- TODO: Types for config schema
M.defaults = { M.defaults = {
guides = { guides = {
enabled = true, enabled = true,

View File

@@ -1,11 +1,13 @@
local M = {} local M = {}
local cfg = require 'outline.config' local cfg = require 'outline.config'
M.is_foldable = function(node) ---@param node outline.SymbolNode|outline.FlatSymbolNode
function M.is_foldable(node)
return node.children and #node.children > 0 return node.children and #node.children > 0
end end
local get_default_folded = function(depth) ---@param depth integer
local function get_default_folded(depth)
local fold_past = cfg.o.symbol_folding.autofold_depth local fold_past = cfg.o.symbol_folding.autofold_depth
if not fold_past then if not fold_past then
return false return false
@@ -14,7 +16,8 @@ local get_default_folded = function(depth)
end end
end end
M.is_folded = function(node) ---@param node outline.SymbolNode|outline.FlatSymbolNode
function M.is_folded(node)
if node.folded ~= nil then if node.folded ~= nil then
return node.folded return node.folded
elseif node.hovered and cfg.o.symbol_folding.auto_unfold_hover then elseif node.hovered and cfg.o.symbol_folding.auto_unfold_hover then

View File

@@ -43,7 +43,9 @@ end
-- STATE -- STATE
------------------------- -------------------------
M.state = { M.state = {
---@type outline.SymbolNode[]
outline_items = {}, outline_items = {},
---@type outline.FlatSymbolNode[]
flattened_outline_items = {}, flattened_outline_items = {},
code_win = 0, code_win = 0,
-- In case unhide_cursor was called before hide_cursor for _some_ reason, -- In case unhide_cursor was called before hide_cursor for _some_ reason,
@@ -56,10 +58,10 @@ local function wipe_state()
end end
local function _update_lines() local function _update_lines()
M.state.flattened_outline_items = parser.flatten(M.state.outline_items) M.state.flattened_outline_items = writer.make_outline(M.view.bufnr, M.state.outline_items)
writer.parse_and_write(M.view.bufnr, M.state.flattened_outline_items)
end end
---@param items outline.SymbolNode[]
local function _merge_items(items) local function _merge_items(items)
utils.merge_items_rec( utils.merge_items_rec(
{ children = items }, { children = items },
@@ -90,11 +92,13 @@ end
M._refresh = utils.debounce(__refresh, 100) M._refresh = utils.debounce(__refresh, 100)
---@return outline.FlatSymbolNode
function M._current_node() function M._current_node()
local current_line = vim.api.nvim_win_get_cursor(M.view.winnr)[1] local current_line = vim.api.nvim_win_get_cursor(M.view.winnr)[1]
return M.state.flattened_outline_items[current_line] return M.state.flattened_outline_items[current_line]
end end
---@param change_focus boolean
function M.__goto_location(change_focus) function M.__goto_location(change_focus)
local node = M._current_node() local node = M._current_node()
vim.api.nvim_win_set_cursor( vim.api.nvim_win_set_cursor(
@@ -107,7 +111,9 @@ function M.__goto_location(change_focus)
end end
end end
-- Wraps __goto_location and handles auto_close ---Wraps __goto_location and handles auto_close.
---@see __goto_location
---@param change_focus boolean
function M._goto_location(change_focus) function M._goto_location(change_focus)
M.__goto_location(change_focus) M.__goto_location(change_focus)
if change_focus and cfg.o.outline_window.auto_close then if change_focus and cfg.o.outline_window.auto_close then
@@ -120,6 +126,7 @@ function M._goto_and_close()
M.close_outline() M.close_outline()
end end
---@param direction "up"|"down"
function M._move_and_goto(direction) function M._move_and_goto(direction)
local move = direction == 'down' and 1 or -1 local move = direction == 'down' and 1 or -1
local cur = vim.api.nvim_win_get_cursor(0) local cur = vim.api.nvim_win_get_cursor(0)
@@ -128,6 +135,8 @@ function M._move_and_goto(direction)
M.__goto_location(false) M.__goto_location(false)
end end
---@param move_cursor boolean
---@param node_index integer Index for M.state.flattened_outline_items
function M._toggle_fold(move_cursor, node_index) function M._toggle_fold(move_cursor, node_index)
local node = M.state.flattened_outline_items[node_index] or M._current_node() local node = M.state.flattened_outline_items[node_index] or M._current_node()
local is_folded = folding.is_folded(node) local is_folded = folding.is_folded(node)
@@ -186,6 +195,9 @@ local function setup_buffer_autocmd()
end end
end end
---@param folded boolean
---@param move_cursor? boolean
---@param node_index? integer
function M._set_folded(folded, move_cursor, node_index) function M._set_folded(folded, move_cursor, node_index)
local node = M.state.flattened_outline_items[node_index] or M._current_node() local node = M.state.flattened_outline_items[node_index] or M._current_node()
local changed = (folded ~= folding.is_folded(node)) local changed = (folded ~= folding.is_folded(node))
@@ -212,6 +224,7 @@ function M._set_folded(folded, move_cursor, node_index)
end end
end end
---@param nodes outline.SymbolNode[]
function M._toggle_all_fold(nodes) function M._toggle_all_fold(nodes)
nodes = nodes or M.state.outline_items nodes = nodes or M.state.outline_items
local folded = true local folded = true
@@ -226,6 +239,8 @@ function M._toggle_all_fold(nodes)
M._set_all_folded(not folded, nodes) M._set_all_folded(not folded, nodes)
end end
---@param folded boolean|nil
---@param nodes? outline.SymbolNode[]
function M._set_all_folded(folded, nodes) function M._set_all_folded(folded, nodes)
local stack = { nodes or M.state.outline_items } local stack = { nodes or M.state.outline_items }
@@ -242,6 +257,7 @@ function M._set_all_folded(folded, nodes)
_update_lines() _update_lines()
end end
---@param winnr? integer Window number of code window
function M._highlight_current_item(winnr) function M._highlight_current_item(winnr)
local has_provider = M.has_provider() local has_provider = M.has_provider()
local has_outline_open = M.view:is_open() local has_outline_open = M.view:is_open()
@@ -373,6 +389,8 @@ local function setup_keymaps(bufnr)
end) end)
end end
---@param response table?
---@param opts outline.OutlineOpts?
local function handler(response, opts) local function handler(response, opts)
if response == nil or type(response) ~= 'table' or M.view:is_open() then if response == nil or type(response) ~= 'table' or M.view:is_open() then
return return
@@ -394,9 +412,7 @@ local function handler(response, opts)
local items = parser.parse(response) local items = parser.parse(response)
M.state.outline_items = items M.state.outline_items = items
M.state.flattened_outline_items = parser.flatten(items) M.state.flattened_outline_items = writer.make_outline(M.view.bufnr, items)
writer.parse_and_write(M.view.bufnr, M.state.flattened_outline_items)
M._highlight_current_item(M.state.code_win) M._highlight_current_item(M.state.code_win)
@@ -405,9 +421,12 @@ local function handler(response, opts)
end end
end end
---@class outline.OutlineOpts
---@field focus_outline boolean
---Set position of outline window to match cursor position in code, return ---Set position of outline window to match cursor position in code, return
---whether the window is just newly opened (previously not open). ---whether the window is just newly opened (previously not open).
---@param opts table? Field `focus_outline` = `false` or `nil` means don't focus on outline window after following cursor. If opts is not provided, focus will be on outline window after following cursor. ---@param opts outline.OutlineOpts? Field `focus_outline` = `false` or `nil` means don't focus on outline window after following cursor. If opts is not provided, focus will be on outline window after following cursor.
---@return boolean ok Whether it was successful. If ok=false, either the outline window is not open or the code window cannot be found. ---@return boolean ok Whether it was successful. If ok=false, either the outline window is not open or the code window cannot be found.
function M.follow_cursor(opts) function M.follow_cursor(opts)
if not M.view:is_open() then if not M.view:is_open() then
@@ -449,7 +468,8 @@ end
---Toggle the outline window, and return whether the outline window is open ---Toggle the outline window, and return whether the outline window is open
---after this operation. ---after this operation.
---@param opts table? Table of options, @see open_outline ---@see open_outline
---@param opts outline.OutlineOpts? Table of options
---@return boolean is_open Whether outline window is open ---@return boolean is_open Whether outline window is open
function M.toggle_outline(opts) function M.toggle_outline(opts)
if M.view:is_open() then if M.view:is_open() then
@@ -471,7 +491,7 @@ local function _cmd_toggle_outline(opts)
end end
---Open the outline window. ---Open the outline window.
---@param opts table? Field focus_outline=false means don't focus on outline window after opening. If opts is not provided, focus will be on outline window after opening. ---@param opts outline.OutlineOpts? Field focus_outline=false means don't focus on outline window after opening. If opts is not provided, focus will be on outline window after opening.
function M.open_outline(opts) function M.open_outline(opts)
if not opts then if not opts then
opts = { focus_outline = true } opts = { focus_outline = true }

View File

@@ -7,24 +7,44 @@ local folding = require 'outline.folding'
local M = {} local M = {}
---Parses result from LSP into a table of symbols ---@class outline.SymbolNode
---@field name string
---@field depth integer
---@field parent outline.SymbolNode
---@field deprecated boolean
---@field kind integer|string
---@field icon string
---@field detail string
---@field line integer
---@field character integer
---@field range_start integer
---@field range_end integer
---@field isLast boolean
---@field hierarchy boolean
---@field children? outline.SymbolNode[]
---@field traversal_child integer
---Parses result from LSP into a reorganized tree of symbols (not flattened,
-- simply reoganized by merging each property table from the arguments into a
-- table for each symbol)
---@param result table The result from a language server. ---@param result table The result from a language server.
---@param depth number? The current depth of the symbol in the hierarchy. ---@param depth number? The current depth of the symbol in the hierarchy.
---@param hierarchy table? A table of booleans which tells if a symbols parent was the last in its group. ---@param hierarchy table? A table of booleans which tells if a symbols parent was the last in its group.
---@param parent table? A reference to the current symbol's parent in the function's recursion ---@param parent table? A reference to the current symbol's parent in the function's recursion
---@return table ---@return outline.SymbolNode[]
local function parse_result(result, depth, hierarchy, parent) local function parse_result(result, depth, hierarchy, parent)
local ret = {} local ret = {}
for index, value in pairs(result) do for index, value in pairs(result) do
if not cfg.is_symbol_blacklisted(symbols.kinds[value.kind]) then if not cfg.is_symbol_blacklisted(symbols.kinds[value.kind]) then
-- the hierarchy is basically a table of booleans which tells whether -- the hierarchy is basically a table of booleans which
-- the parent was the last in its group or not -- tells whether the parent was the last in its group or
-- not
local hir = hierarchy or {} local hir = hierarchy or {}
-- how many parents this node has, 1 is the lowest value because its -- how many parents this node has, 1 is the lowest value because its
-- easier to work it -- easier to work it
local level = depth or 1 local level = depth or 1
-- whether this node is the last in its group -- whether this node is the last (~born~) in its siblings
local isLast = index == #result local isLast = index == #result
local selectionRange = lsp_utils.get_selection_range(value) local selectionRange = lsp_utils.get_selection_range(value)
@@ -44,6 +64,7 @@ local function parse_result(result, depth, hierarchy, parent)
isLast = isLast, isLast = isLast,
hierarchy = hir, hierarchy = hir,
parent = parent, parent = parent,
traversal_child = 1,
} }
table.insert(ret, node) table.insert(ret, node)
@@ -54,6 +75,8 @@ local function parse_result(result, depth, hierarchy, parent)
local child_hir = t_utils.array_copy(hir) local child_hir = t_utils.array_copy(hir)
table.insert(child_hir, isLast) table.insert(child_hir, isLast)
children = parse_result(value.children, level + 1, child_hir, node) children = parse_result(value.children, level + 1, child_hir, node)
else
value.children = {}
end end
node.children = children node.children = children
@@ -62,195 +85,48 @@ local function parse_result(result, depth, hierarchy, parent)
return ret return ret
end end
---Parses the response from lsp request 'textDocument/documentSymbol' using buf_request_all ---Sorts and reorganizes the response from lsp request
--'textDocument/documentSymbol', buf_request_all.
---Used when refreshing and setting up new symbols
---@param response table The result from buf_request_all ---@param response table The result from buf_request_all
---@return table outline items ---@return outline.SymbolNode[]
function M.parse(response) function M.parse(response)
local sorted = lsp_utils.sort_symbols(response) local sorted = lsp_utils.sort_symbols(response)
return parse_result(sorted, nil, nil) return parse_result(sorted, nil, nil)
end end
function M.flatten(outline_items, ret, depth) ---Iterator that traverses the tree parent first before children, returning each node.
depth = depth or 1 -- Essentailly 'flatten' items, but returns an iterator.
ret = ret or {} ---@param items outline.SymbolNode[] Tree of symbols parsed by parse_result
for _, value in ipairs(outline_items) do function M.preorder_iter(items)
table.insert(ret, value) local node = { children = items, traversal_child = 1, depth = 1, folded = false }
value.line_in_outline = #ret local prev
if value.children ~= nil and not folding.is_folded(value) then local visited = {}
M.flatten(value.children, ret, depth + 1)
end
end
-- if depth == 1 then return function()
-- for index, value in ipairs(ret) do while node do
-- value.line_in_outline = index if node.name and not visited[node] then
-- end visited[node] = true
-- end return node
end
return ret if
end node.children and node.traversal_child <= #node.children
and not folding.is_folded(node)
function M.get_lines(flattened_outline_items) then
local lines = {} prev = node
local hl_info = {} if node.children[node.traversal_child] then
local guide_hl_info = {} node.children[node.traversal_child].parent_node = node
local lineno_max = 0 node = node.children[node.traversal_child]
for node_line, node in ipairs(flattened_outline_items) do
local depth = node.depth
local marker_space = (cfg.o.symbol_folding.markers and 1) or 0
local line = t_utils.str_to_table(string.rep(' ', depth + marker_space))
local running_length = 1
if node.range_start+1 > lineno_max then
lineno_max = node.range_start+1
end
local function add_guide_hl(from, to)
table.insert(guide_hl_info, {
node_line,
from,
to,
'OutlineGuides',
})
end
for index, _ in ipairs(line) do
if cfg.o.guides.enabled then
local guide_markers = cfg.o.guides.markers
if index == 1 then
line[index] = ''
-- if index is last, add a bottom marker if current item is last,
-- else add a middle marker
elseif index == #line then
-- add fold markers
local fold_markers = cfg.o.symbol_folding.markers
if fold_markers and folding.is_foldable(node) then
if folding.is_folded(node) then
line[index] = fold_markers[1]
else
line[index] = fold_markers[2]
end
add_guide_hl(
running_length,
running_length + vim.fn.strlen(line[index]) - 1
)
-- the root level has no vertical markers
elseif depth > 1 then
if node.isLast then
line[index] = guide_markers.bottom
add_guide_hl(
running_length,
running_length + vim.fn.strlen(guide_markers.bottom) - 1
)
else
line[index] = guide_markers.middle
add_guide_hl(
running_length,
running_length + vim.fn.strlen(guide_markers.middle) - 1
)
end
end
-- else if the parent was not the last in its group, add a
-- vertical marker because there are items under us and we need
-- to point to those
elseif not node.hierarchy[index] and depth > 1 then
line[index + marker_space] = guide_markers.vertical
add_guide_hl(
running_length - 1 + 2 * marker_space,
running_length
+ vim.fn.strlen(guide_markers.vertical)
- 1
+ 2 * marker_space
)
end end
end prev.traversal_child = prev.traversal_child + 1
else
line[index] = line[index] .. ' ' node.traversal_child = 1
node = node.parent_node
running_length = running_length + vim.fn.strlen(line[index])
end
line[1] = ''
local final_prefix = line
local string_prefix = t_utils.table_to_str(final_prefix)
table.insert(lines, string_prefix .. node.icon .. ' ' .. node.name)
local hl_start = #string_prefix
local hl_end = #string_prefix + #node.icon
local hl_type = cfg.o.symbols.icons[symbols.kinds[node.kind]].hl
table.insert(hl_info, { node_line, hl_start, hl_end, hl_type })
node.prefix_length = #string_prefix + #node.icon + 1
end
local final_hl = {}
if cfg.o.outline_items.show_symbol_lineno then
-- Width of the highest lineno value
local max_width = #tostring(lineno_max)
-- Padded prefix to the right of lineno for better readability if linenos
-- get more than 2 digits.
local prefix = string.rep(' ', math.max(2, max_width)+1)
-- Offset to hl_info due to adding lineno on the left of each symbol line
local total_offset = #prefix
for i, node in ipairs(flattened_outline_items) do
lines[i] = prefix .. lines[i]
table.insert(final_hl, {
hl_info[i][1], -- node_line
hl_info[i][2] + total_offset, -- start
hl_info[i][3] + total_offset, -- end
hl_info[i][4] -- type
})
node.prefix_length = node.prefix_length + total_offset
end
if cfg.o.guides.enabled then
for _, hl in ipairs(guide_hl_info) do
table.insert(final_hl, {
hl[1],
hl[2] + total_offset,
hl[3] + total_offset,
hl[4]
})
end
end
else
-- Merge lists hl_info and guide_hl_info
final_hl = hl_info
if cfg.o.guides.enabled then
for _, hl in ipairs(guide_hl_info) do
table.insert(final_hl, hl)
end end
end end
end end
return lines, final_hl
end
function M.get_details(flattened_outline_items)
local lines = {}
for _, value in ipairs(flattened_outline_items) do
table.insert(lines, value.detail or '')
end
return lines
end
function M.get_lineno(flattened_outline_items)
local lines = {}
local max = 0
for _, value in ipairs(flattened_outline_items) do
local line = value.range_start+1
if line > max then
max = line
end
-- Not padded here
table.insert(lines, tostring(line))
end
return lines, max
end end
return M return M

View File

@@ -26,6 +26,7 @@ function M.has_provider()
end end
---@param on_symbols function ---@param on_symbols function
---@param opts outline.OutlineOpts?
---@return boolean found_provider ---@return boolean found_provider
function M.request_symbols(on_symbols, opts) function M.request_symbols(on_symbols, opts)
for _, value in ipairs(providers) do for _, value in ipairs(providers) do

View File

@@ -21,6 +21,7 @@ function M.setup_highlights()
-- Setup the OutlineCurrent highlight group if it hasn't been done already by -- Setup the OutlineCurrent highlight group if it hasn't been done already by
-- a theme or manually set -- a theme or manually set
if vim.fn.hlexists 'OutlineCurrent' == 0 then if vim.fn.hlexists 'OutlineCurrent' == 0 then
-- TODO: Use nvim_get_hl
local cline_hl = vim.api.nvim_get_hl_by_name('CursorLine', true) local cline_hl = vim.api.nvim_get_hl_by_name('CursorLine', true)
local string_hl = vim.api.nvim_get_hl_by_name('String', true) local string_hl = vim.api.nvim_get_hl_by_name('String', true)

View File

@@ -83,7 +83,8 @@ local function range_compare(a, b)
return (a.line < b.line) or (a.line == b.line and a.character < b.character) return (a.line < b.line) or (a.line == b.line and a.character < b.character)
end end
--- Sorts the result from LSP by where the symbols start. ---Sort the result from LSP by where the symbols start.
---Recursively sorts all children of each symbol
function M.sort_symbols(symbols) function M.sort_symbols(symbols)
table.sort(symbols, function(a, b) table.sort(symbols, function(a, b)
return range_compare( return range_compare(

View File

@@ -1,10 +1,22 @@
local symbols = require 'outline.symbols'
local parser = require 'outline.parser' local parser = require 'outline.parser'
local cfg = require('outline.config') local cfg = require 'outline.config'
local ui = require 'outline.ui' local ui = require 'outline.ui'
local t_utils = require 'outline.utils.table'
local folding = require 'outline.folding'
local strlen = vim.fn.strlen
local M = {} local M = {}
local function is_buffer_outline(bufnr) local hlns = vim.api.nvim_create_namespace 'outline-icon-highlight'
local ns = vim.api.nvim_create_namespace 'outline-virt-text'
---@param bufnr integer
---@return boolean
function M.is_buffer_outline(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then if not vim.api.nvim_buf_is_valid(bufnr) then
return false return false
end end
@@ -13,23 +25,9 @@ local function is_buffer_outline(bufnr)
return string.match(name, 'OUTLINE') ~= nil and ft == 'Outline' return string.match(name, 'OUTLINE') ~= nil and ft == 'Outline'
end end
local hlns = vim.api.nvim_create_namespace 'outline-icon-highlight' ---Apply highlights and hover highlights to bufnr
---@param bufnr integer
function M.write_outline(bufnr, lines) ---@param nodes outline.FlatSymbolNode[] flattened nodes
if not is_buffer_outline(bufnr) then
return
end
lines = vim.tbl_map(function(line)
lines, _ = string.gsub(line, "\n", " ")
return lines
end, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
end
function M.add_highlights(bufnr, hl_info, nodes) function M.add_highlights(bufnr, hl_info, nodes)
for _, line_hl in ipairs(hl_info) do for _, line_hl in ipairs(hl_info) do
local line, hl_start, hl_end, hl_type = unpack(line_hl) local line, hl_start, hl_end, hl_type = unpack(line_hl)
@@ -42,54 +40,17 @@ function M.add_highlights(bufnr, hl_info, nodes)
hl_end hl_end
) )
end end
M.add_hover_highlights(bufnr, nodes) M.add_hover_highlights(bufnr, nodes)
end end
local ns = vim.api.nvim_create_namespace 'outline-virt-text' ---@param bufnr integer
function M.write_details(bufnr, lines)
if not is_buffer_outline(bufnr) then
return
end
if not cfg.o.outline_items.show_symbol_details then
return
end
for index, value in ipairs(lines) do
vim.api.nvim_buf_set_extmark(bufnr, ns, index - 1, -1, {
virt_text = { { value, 'OutlineDetails' } },
virt_text_pos = 'eol',
hl_mode = 'combine',
})
end
end
function M.write_lineno(bufnr, lines, max)
if not is_buffer_outline(bufnr) then
return
end
if not cfg.o.outline_items.show_symbol_lineno then
return
end
local maxwidth = #tostring(max)
for index, value in ipairs(lines) do
local leftpad = string.rep(' ', maxwidth-#value)
vim.api.nvim_buf_set_extmark(bufnr, ns, index - 1, -1, {
virt_text = { {leftpad..value, 'OutlineLineno' } },
virt_text_pos = 'overlay',
virt_text_win_col = 0,
hl_mode = 'combine',
})
end
end
local function clear_virt_text(bufnr) local function clear_virt_text(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1) vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
end end
M.add_hover_highlights = function(bufnr, nodes) ---@param bufnr integer
---@param nodes outline.FlatSymbolNode[] flattened nodes
function M.add_hover_highlights (bufnr, nodes)
if not cfg.o.outline_items.highlight_hovered_item then if not cfg.o.outline_items.highlight_hovered_item then
return return
end end
@@ -101,7 +62,6 @@ M.add_hover_highlights = function(bufnr, nodes)
goto continue goto continue
end end
local marker_fac = (cfg.o.symbol_folding.markers and 1) or 0
if node.prefix_length then if node.prefix_length then
ui.add_hover_highlight( ui.add_hover_highlight(
bufnr, bufnr,
@@ -113,18 +73,207 @@ M.add_hover_highlights = function(bufnr, nodes)
end end
end end
-- runs the whole writing routine where the text is cleared, new data is parsed ---@class outline.FlatSymbolNode
-- and then written ---@field name string
function M.parse_and_write(bufnr, flattened_outline_items) ---@field depth integer
local lines, hl_info = parser.get_lines(flattened_outline_items) ---@field parent outline.SymbolNode
M.write_outline(bufnr, lines) ---@field deprecated boolean
---@field kind integer|string
---@field icon string
---@field detail string
---@field line integer
---@field character integer
---@field range_start integer
---@field range_end integer
---@field isLast boolean
---@field hierarchy boolean
---@field children? outline.SymbolNode[]
---@field traversal_child integer
---@field line_in_outline integer
---@field prefix_length integer
---@field hovered boolean
---@field folded boolean
---The quintessential function of this entire plugin. Clears virtual text,
-- parses each node and replaces old lines with new lines to be written for the
-- outline buffer.
-- Handles highlights, virtual text, and of course lines of outline to write
---@param bufnr integer Nothing is done if is_buffer_outline(bufnr) is not true
---@param items outline.SymbolNode[] Tree of symbols after being parsed by parser.parse_result
---@return outline.FlatSymbolNode[]? flattened_items No return value if bufnr is invalid
function M.make_outline(bufnr, items)
if not M.is_buffer_outline(bufnr) then
return
end
clear_virt_text(bufnr) clear_virt_text(bufnr)
local details = parser.get_details(flattened_outline_items)
local lineno, lineno_max = parser.get_lineno(flattened_outline_items) ---@type string[]
M.add_highlights(bufnr, hl_info, flattened_outline_items) local lines = {}
M.write_details(bufnr, details) ---@type string[]
M.write_lineno(bufnr, lineno, lineno_max) local details = {}
---@type string[]
local linenos = {}
---@type outline.FlatSymbolNode[]
local flattened = {}
local hl = {}
-- Find the prefix for each line needed for the lineno space
local lineno_offset = 0
local lineno_prefix = ""
-- FIXME: Why is the +1 at the end needed? (Otherwise numbers are misaligned)
local lineno_max_width = #tostring(vim.api.nvim_buf_line_count(bufnr) - 1) + 1
if cfg.o.outline_items.show_symbol_lineno then
-- Use max width-1 plus 1 space padding.
-- -1 because if max_width is a power of ten, don't shift the entire lineno
-- column by the right just because the last line number requires an extra
-- digit. If max_width is 1000, the lineno column will take up 3 columns to
-- fill the digits, and 1 padding on the right. The 1000 can fit perfectly
-- there.
lineno_offset = math.max(2, lineno_max_width) + 1
lineno_prefix = string.rep(' ', lineno_offset)
end
-- Closures for convenience
local function add_guide_hl(from, to)
table.insert(hl, {
#flattened,
from + lineno_offset,
to + lineno_offset,
"OutlineGuides"
})
end
local function add_fold_hl(from, to)
table.insert(hl, {
#flattened,
from + lineno_offset,
to + lineno_offset,
"OutlineFoldMarker"
})
end
local guide_markers = cfg.o.guides.markers
local fold_markers = cfg.o.symbol_folding.markers
for node in parser.preorder_iter(items) do
table.insert(flattened, node)
node.line_in_outline = #flattened
table.insert(details, node.detail or '')
table.insert(linenos, tostring(node.range_start+1))
-- Make the guides for the line prefix
local pref = t_utils.str_to_table(string.rep(' ', node.depth))
local fold_marker_width = 0
if folding.is_foldable(node) then
-- Add fold marker
local marker = fold_markers[2]
if folding.is_folded(node) then
marker = fold_markers[1]
end
pref[#pref] = marker
fold_marker_width = strlen(marker)
else
-- Rightmost guide for the immediate parent, only added if fold marker is
-- not added
if node.depth > 1 then
local marker = guide_markers.middle
if node.isLast then
marker = guide_markers.bottom
end
pref[#pref] = marker
end
end
-- Add vertical guides to the left, for all parents that isn't the last
-- sibling. Iter from first grandparent until second last ancestor (last
-- ancestor is the entire outline itself, it should not have a vertical
-- guide).
local iternode = node
for i = node.depth-1, 2, -1 do
iternode = iternode.parent_node
if not iternode.isLast then
pref[i] = guide_markers.vertical
end
end
-- Finished with guide prefix
-- Join all prefix chars by a space
local pref_str = table.concat(pref, ' ')
-- Guide hl goes from start of prefix till before the fold marker, if any.
-- Fold hl goes from start of fold marker until before the icon.
add_guide_hl(lineno_offset, #pref_str - fold_marker_width)
if fold_marker_width > 0 then
add_fold_hl(#pref_str - fold_marker_width, #pref_str + 1)
end
local line = lineno_prefix..pref_str..' '..node.icon..' '..node.name
-- Highlight for the icon ✨
local hl_start = #pref_str + #lineno_prefix + 1 -- Start from icon col
local hl_end = hl_start + #node.icon -- until after icon
local hl_type = cfg.o.symbols.icons[symbols.kinds[node.kind]].hl
table.insert(hl, { #flattened, hl_start, hl_end, hl_type })
-- Prefix length is from start until the beginning of the node.name, used
-- for hover highlights.
node.prefix_length = hl_end + 1
-- lines passed to nvim_buf_set_lines cannot contain newlines in each line
line = line:gsub("\n", " ")
table.insert(lines, line)
end
-- Write the lines 🎉
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
-- Unfortunately highlights and extmarks cannot be added to lines that do not
-- yet exist. Hence this requires another O(n) of iteration.
M.add_highlights(bufnr, hl, flattened)
-- Add details and lineno virtual text.
if cfg.o.outline_items.show_symbol_details then
for index, value in ipairs(details) do
vim.api.nvim_buf_set_extmark(bufnr, ns, index - 1, -1, {
virt_text = { { value, 'OutlineDetails' } },
virt_text_pos = 'eol',
hl_mode = 'combine',
})
end
end
if cfg.o.outline_items.show_symbol_lineno then
-- Line numbers are left padded, right aligned, positioned at the leftmost
-- column
for index, value in ipairs(linenos) do
local leftpad = string.rep(' ', lineno_max_width-#value)
vim.api.nvim_buf_set_extmark(bufnr, ns, index - 1, -1, {
virt_text = { {leftpad..value, 'OutlineLineno' } },
virt_text_pos = 'overlay',
virt_text_win_col = 0,
hl_mode = 'combine',
})
end
end
return flattened
end end
-- XXX: Is the performance tradeoff of calling `nvim_buf_set_lines` on each
-- iteration worth it in order to put setting of highlights, details, and
-- linenos together with each line?
-- That is,
-- 1. { call nvim_buf_set_lines once for all lines }
-- + { O(n) for each of highlights, details, and linenos }
--OR
-- 2. { call nvim_buf_set_lines for each line }
-- + { O(1) for each of highlight/detail/lineno the same iteration }
-- It appears that for highlight/detail/lineno, the number of calls to nvim API
-- is the same, only 3 extra tables in memory for (1). Where as for (2) you
-- have to call nvim_buf_set_lines n times (each line) rather than add lines
-- all at once, saving only the need of 1 extra table (lines table) in memory.
return M return M