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

@@ -7,24 +7,44 @@ local folding = require 'outline.folding'
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 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 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 ret = {}
for index, value in pairs(result) do
if not cfg.is_symbol_blacklisted(symbols.kinds[value.kind]) then
-- the hierarchy is basically a table of booleans which tells whether
-- the parent was the last in its group or not
-- the hierarchy is basically a table of booleans which
-- tells whether the parent was the last in its group or
-- not
local hir = hierarchy or {}
-- how many parents this node has, 1 is the lowest value because its
-- easier to work it
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 selectionRange = lsp_utils.get_selection_range(value)
@@ -44,6 +64,7 @@ local function parse_result(result, depth, hierarchy, parent)
isLast = isLast,
hierarchy = hir,
parent = parent,
traversal_child = 1,
}
table.insert(ret, node)
@@ -54,6 +75,8 @@ local function parse_result(result, depth, hierarchy, parent)
local child_hir = t_utils.array_copy(hir)
table.insert(child_hir, isLast)
children = parse_result(value.children, level + 1, child_hir, node)
else
value.children = {}
end
node.children = children
@@ -62,195 +85,48 @@ local function parse_result(result, depth, hierarchy, parent)
return ret
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
---@return table outline items
---@return outline.SymbolNode[]
function M.parse(response)
local sorted = lsp_utils.sort_symbols(response)
return parse_result(sorted, nil, nil)
end
function M.flatten(outline_items, ret, depth)
depth = depth or 1
ret = ret or {}
for _, value in ipairs(outline_items) do
table.insert(ret, value)
value.line_in_outline = #ret
if value.children ~= nil and not folding.is_folded(value) then
M.flatten(value.children, ret, depth + 1)
end
end
---Iterator that traverses the tree parent first before children, returning each node.
-- Essentailly 'flatten' items, but returns an iterator.
---@param items outline.SymbolNode[] Tree of symbols parsed by parse_result
function M.preorder_iter(items)
local node = { children = items, traversal_child = 1, depth = 1, folded = false }
local prev
local visited = {}
-- if depth == 1 then
-- for index, value in ipairs(ret) do
-- value.line_in_outline = index
-- end
-- end
return function()
while node do
if node.name and not visited[node] then
visited[node] = true
return node
end
return ret
end
function M.get_lines(flattened_outline_items)
local lines = {}
local hl_info = {}
local guide_hl_info = {}
local lineno_max = 0
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
)
if
node.children and node.traversal_child <= #node.children
and not folding.is_folded(node)
then
prev = node
if node.children[node.traversal_child] then
node.children[node.traversal_child].parent_node = node
node = node.children[node.traversal_child]
end
end
line[index] = line[index] .. ' '
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)
prev.traversal_child = prev.traversal_child + 1
else
node.traversal_child = 1
node = node.parent_node
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
return M