feat: Symbol filtering config structure

This commit introduces a basic framework for symbol filtering in
outline.nvim, where users can set per-filetype kinds to filter - include
or exclude for each filetype.

As a side effect the checking of symbol inclusion function has been
improved to O(1) time-complexity (previously O(n)). You can see this
from types/outline.lua and config.lua: a lookup table is used to check
if a kind is filtered, rather than looping through a list each time.
Former takes O(1) for lookup whereas the old implementation would be
O(n) for *each* node!

The old symbols.blacklist option *still works as expected*.

The schema for the new confit is detailed in #23 and types/outline.lua.
By the way, this commit also closes #23.

These should equivalent:
    symbols.blacklist = { 'Function', 'Method' }
    symbols.filter = { 'Function', 'Method', exclude=true }
    symbols.filter = {
      ['*'] = { 'Function', 'Method', exclude=true }
    }

And these should be equivalent:
    symbols.blacklist = {}
    symbols.filter = false
    symbols.filter = nil
    symbols.filter = { ['*'] = false }
    symbols.filter = { ['*'] = { exclude = true } }
    symbols.filter = { exclude = true }

The last two of which could be considered unidiomatic.

When multiple filetypes are specified, filetype specific filters
are NOT merged with the default ('*') filter, they are independent. If a
filetype is used, the default filter is not considered. The default
filter is only considered if a filetype filter for the given buffer is
not provided.

LIMITATIONS:
This is carried over from the implementation from symbols-outline:
filters can only be applied to parents at the moment. I.e.: If some node
has a kind that is excluded, all its children will NOT be considered.

Filters are only applied to children if its parent was not excluded
during filtering.

Also extracted all types into types module, and updated conversion
script to use the new symbols.filter opt.

NOTE:
On outline open it appears that parsing functions are called twice?
I should definitely add tests soon.
This commit is contained in:
hedy
2023-11-16 21:21:55 +08:00
parent 5278eb5b2b
commit 24680f13f7
7 changed files with 244 additions and 80 deletions

View File

@@ -1,8 +1,7 @@
local utils = require('outline.utils') local utils = require('outline.utils')
local M = {} local M = {}
local all_kinds = {'File', 'Module', 'Namespace', 'Package', 'Class', 'Method', 'Property', 'Field', 'Constructor', 'Enum', 'Interface', 'Function', 'Variable', 'Constant', 'String', 'Number', 'Boolean', 'Array', 'Object', 'Key', 'Null', 'EnumMember', 'Struct', 'Event', 'Operator', 'TypeParameter', 'Component', 'Fragment', 'TypeAlias', 'Parameter', 'StaticMethod', 'Macro',}
-- TODO: Types for config schema
M.defaults = { M.defaults = {
guides = { guides = {
@@ -77,7 +76,8 @@ M.defaults = {
}, },
}, },
symbols = { symbols = {
blacklist = {}, ---@type outline.FilterConfig?
filter = nil,
icon_source = nil, icon_source = nil,
icon_fetcher = nil, icon_fetcher = nil,
icons = { icons = {
@@ -166,6 +166,16 @@ function M.get_split_command()
end end
end end
---Whether table == {}
---@param t table
local function is_empty_table(t)
return t and next(t) == nil
end
local function table_has_content(t)
return t and next(t) ~= nil
end
local function has_value(tab, val) local function has_value(tab, val)
for _, value in ipairs(tab) do for _, value in ipairs(tab) do
if value == val then if value == val then
@@ -176,11 +186,33 @@ local function has_value(tab, val)
return false return false
end end
function M.is_symbol_blacklisted(kind) ---Determine whether to include symbol in outline based on bufnr and its kind
if kind == nil then ---@param kind string
return false ---@param bufnr integer
---@return boolean include
function M.should_include_symbol(kind, bufnr)
local ft = vim.api.nvim_buf_get_option(bufnr, 'ft')
-- There can only be one kind in markdown as of now
if ft == 'markdown' or kind == nil then
return true
end end
return has_value(M.o.symbols.blacklist, kind)
local filter_table = M.o.symbols.filter[ft]
local default_filter_table = M.o.symbols.filter['*']
-- When filter table for a ft is not specified, all symbols are shown
if not filter_table then
if not default_filter_table then
return true
else
return default_filter_table[kind] ~= false
end
end
-- XXX: If the given kind is not known by outline.nvim (ie: not in
-- all_kinds), still return true. Only exclude those symbols that were
-- explicitly filtered out.
return filter_table[kind] ~= false
end end
---@param client vim.lsp.client|number ---@param client vim.lsp.client|number
@@ -197,6 +229,8 @@ function M.is_client_blacklisted(client)
return has_value(M.o.providers.lsp.blacklist_clients, client.name) return has_value(M.o.providers.lsp.blacklist_clients, client.name)
end end
---Retrieve and cache import paths of all providers in order of given priority
---@return string[]
function M.get_providers() function M.get_providers()
if M.providers then if M.providers then
return M.providers return M.providers
@@ -217,15 +251,26 @@ function M.show_help()
print(vim.inspect(M.o.keymaps)) print(vim.inspect(M.o.keymaps))
end end
---Check for inconsistent or mutually exclusive opts. Does not alter the opts ---Check for inconsistent or mutually exclusive opts.
-- Does not alter the opts. Might show messages.
function M.check_config() function M.check_config()
if M.o.outline_window.hide_cursor and not M.o.outline_window.show_cursorline then if M.o.outline_window.hide_cursor and not M.o.outline_window.show_cursorline then
utils.echo("config", "hide_cursor enabled without cursorline enabled!") utils.echo("config", "Warning: hide_cursor enabled without cursorline enabled")
end end
end end
---Resolve shortcuts and deprecated option conversions ---Resolve shortcuts and deprecated option conversions.
-- Might alter opts. Might show messages.
function M.resolve_config() function M.resolve_config()
----- GUIDES -----
local guides = M.o.guides
if type(guides) == 'boolean' then
M.o.guides = M.defaults.guides
if not guides then
M.o.guides.enabled = false
end
end
----- SPLIT COMMAND -----
local sc = M.o.outline_window.split_command local sc = M.o.outline_window.split_command
if sc then if sc then
-- This should not be needed, nor is it failsafe. But in case user only provides -- This should not be needed, nor is it failsafe. But in case user only provides
@@ -234,7 +279,7 @@ function M.resolve_config()
M.o.outline_window.split_command = sc..' vs' M.o.outline_window.split_command = sc..' vs'
end end
end end
----- COMPAT (renaming) -----
local dg = M.o.keymaps.down_and_goto local dg = M.o.keymaps.down_and_goto
local ug = M.o.keymaps.up_and_goto local ug = M.o.keymaps.up_and_goto
if dg then if dg then
@@ -245,23 +290,113 @@ function M.resolve_config()
M.o.keymaps.up_and_jump = ug M.o.keymaps.up_and_jump = ug
M.o.keymaps.up_and_goto = nil M.o.keymaps.up_and_goto = nil
end end
-- if dg or ug then
-- vim.notify("[outline.config]: keymaps down/up_and_goto are renamed to down/up_and_jump. Your keymaps for the current session is converted successfully.", vim.log.levels.WARN)
-- end
if M.o.outline_window.auto_goto then if M.o.outline_window.auto_goto then
M.o.outline_window.auto_jump = M.o.outline_window.auto_goto M.o.outline_window.auto_jump = M.o.outline_window.auto_goto
M.o.outline_window.auto_goto = nil M.o.outline_window.auto_goto = nil
end end
----- SYMBOLS FILTER -----
M.resolve_filter_config()
end
---Ensure l is either table, false, or nil. If not, print warning using given
-- name that describes l, set l to nil, and return l.
---@generic T
---@param l T
---@param name string
---@return T
local function validate_filter_list(l, name)
if type(l) == 'boolean' and l then
utils.echo("config", ("Setting %s to true is undefined behaviour. Defaulting to nil."):format(name))
l = nil
elseif l and type(l) ~= 'table' and type(l) ~= 'boolean' then
utils.echo("config", ("%s must either be a table, false, or nil. Defaulting to nil."):format(name))
l = nil
end
return l
end
---Resolve shortcuts and compat opt for symbol filtering config, and set up
-- `M.o.symbols.filter` to be a proper `outline.FilterFtTable` lookup table.
function M.resolve_filter_config()
---@type outline.FilterConfig
local tmp = M.o.symbols.filter
tmp = validate_filter_list(tmp, "symbols.filter")
---- legacy form -> ft filter list ----
if table_has_content(M.o.symbols.blacklist) then
tmp = { ['*'] = M.o.symbols.blacklist }
tmp['*'].exclude = true
M.o.symbols.blacklist = nil
else
---- nil or {} -> include all symbols ----
-- For filter = {}: theoretically this would make no symbols show up. The
-- user can't possibly want this (they should've disabled the plugin
-- through the plugin manager); so we let filter = {} denote filter = nil
-- (or false), meaning include all symbols.
if not table_has_content(tmp) then
tmp = { ['*'] = { exclude = true } }
-- Lazy filter list -> ft filter list
elseif tmp[1] then
if type(tmp[1]) == 'string' then
tmp = { ['*'] = vim.deepcopy(tmp) }
else
tmp['*'] = vim.deepcopy(tmp[1])
tmp[1] = nil
end
end
end
---@type outline.FilterFtList
local filter = tmp
---@type outline.FilterFtTable
M.o.symbols.filter = {}
---- ft filter list -> lookup table ----
-- We do this so that all the O(N) checks happen once, in the setup phase,
-- and checks for the filter list later on can be speedy.
-- After this operation, filter table would have ft as keys, and for each
-- value, it has each kind key denoting whether to include that kind for this
-- filetype.
-- {
-- python = { String = false, Variable = true, ... },
-- ['*'] = { File = true, Method = true, ... },
-- }
for ft, list in pairs(filter) do
if type(ft) ~= 'string' then
utils.echo("config", "ft (keys) for symbols.filter table can only be string. Skipping this ft.")
goto continue
end
M.o.symbols.filter[ft] = {}
list = validate_filter_list(list, ("filter list for ft '%s'"):format(ft))
-- Ensure boolean.
-- Catches setting some ft = false/nil, meaning include all kinds
if not list then
list = { exclude = true }
else
list.exclude = (list.exclude ~= nil and list.exclude) or false
end
-- If it's an exclude-list, set all kinds to be included (true) by default
-- If it's an inclusive list, set all kinds to be excluded (false) by default
for _, kind in pairs(all_kinds) do
M.o.symbols.filter[ft][kind] = list.exclude
end
-- Now flip the switches
for _, kind in ipairs(list) do
M.o.symbols.filter[ft][kind] = not M.o.symbols.filter[ft][kind]
end
::continue::
end
end end
function M.setup(options) function M.setup(options)
vim.g.outline_loaded = 1 vim.g.outline_loaded = 1
M.o = vim.tbl_deep_extend('force', {}, M.defaults, options or {}) M.o = vim.tbl_deep_extend('force', {}, M.defaults, options or {})
local guides = M.o.guides
if type(guides) == 'boolean' and guides then
M.o.guides = M.defaults.guides
end
M.check_config() M.check_config()
M.resolve_config() M.resolve_config()
end end

View File

@@ -78,11 +78,11 @@ local function __refresh()
return return
end end
local items = parser.parse(response)
_merge_items(items)
M.state.code_win = vim.api.nvim_get_current_win() M.state.code_win = vim.api.nvim_get_current_win()
local items = parser.parse(response, vim.api.nvim_get_current_buf())
_merge_items(items)
_update_lines() _update_lines()
end end
@@ -445,7 +445,7 @@ local function handler(response, opts)
setup_keymaps(M.view.bufnr) setup_keymaps(M.view.bufnr)
setup_buffer_autocmd() setup_buffer_autocmd()
local items = parser.parse(response) local items = parser.parse(response, vim.api.nvim_win_get_buf(M.state.code_win))
M.state.outline_items = items M.state.outline_items = items
M.state.flattened_outline_items = writer.make_outline(M.view.bufnr, items, M.state.code_win) M.state.flattened_outline_items = writer.make_outline(M.view.bufnr, items, M.state.code_win)
@@ -457,11 +457,6 @@ local function handler(response, opts)
end end
end end
---@class outline.OutlineOpts
---@field focus_outline boolean?
---@field on_symbols function?
---@field on_outline_setup function?
---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 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. ---@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.

View File

@@ -7,23 +7,6 @@ local folding = require 'outline.folding'
local M = {} local M = {}
---@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, ---Parses result from LSP into a reorganized tree of symbols (not flattened,
-- simply reoganized by merging each property table from the arguments into a -- simply reoganized by merging each property table from the arguments into a
-- table for each symbol) -- table for each symbol)
@@ -31,12 +14,14 @@ local M = {}
---@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
---@param bufnr integer The buffer number which the result was from
---@return outline.SymbolNode[] ---@return outline.SymbolNode[]
local function parse_result(result, depth, hierarchy, parent) local function parse_result(result, depth, hierarchy, parent, bufnr)
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 -- FIXME: If a parent was excluded, all children will not be considered
if cfg.should_include_symbol(symbols.kinds[value.kind], bufnr) then
-- the hierarchy is basically a table of booleans which -- the hierarchy is basically a table of booleans which
-- tells whether the parent was the last in its group or -- tells whether the parent was the last in its group or
-- not -- not
@@ -74,7 +59,7 @@ local function parse_result(result, depth, hierarchy, parent)
-- copy by value because we dont want it messing with the hir table -- copy by value because we dont want it messing with the hir table
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, bufnr)
else else
value.children = {} value.children = {}
end end
@@ -89,11 +74,12 @@ end
--'textDocument/documentSymbol', buf_request_all. --'textDocument/documentSymbol', buf_request_all.
---Used when refreshing and setting up new symbols ---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
---@param bufnr integer
---@return outline.SymbolNode[] ---@return outline.SymbolNode[]
function M.parse(response) function M.parse(response, bufnr)
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, nil, bufnr)
end end
---Iterator that traverses the tree parent first before children, returning each node. ---Iterator that traverses the tree parent first before children, returning each node.

View File

@@ -0,0 +1,68 @@
-- CONFIG
-- { 'String', 'Variable', exclude = true }
-- If FilterList is nil or false, means include all
---@class outline.FilterList: string[]?
---@field exclude boolean? If nil, means exclude=false, so the list would be an inclusive list.
-- {
-- python = { 'Variable', exclude = true },
-- go = { 'Field', 'Function', 'Method' },
-- ['\*'] = { 'String', exclude = true }
-- }
---@alias outline.FilterFtList { [string]: outline.FilterList } A filter list for each file type
---@alias outline.FilterConfig outline.FilterFtList|outline.FilterList
-- { String = false, Variable = true, File = true, ... }
---@alias outline.FilterTable { [string]: boolean } Each kind:include pair where include is boolean, whether to include this kind. Used internally.
-- {
-- python = { String = false, Variable = true, ... },
-- ['\*'] = { File = true, Method = true, ... },
-- }
---@alias outline.FilterFtTable { [string]: outline.FilterTable } A filter table for each file type. Used internally.
-- 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 Should NOT be modified during iteration using parser.preorder_iter
---@class outline.FlatSymbolNode
---@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
---@field line_in_outline integer
---@field prefix_length integer
---@field hovered boolean
---@field folded boolean
-- API
---@class outline.OutlineOpts
---@field focus_outline boolean? Whether to focus on outline of after some operation. If nil, defaults to true
---@field on_symbols function? After symbols have been received, before sidebar window is setup
---@field on_outline_setup function? After sidebar window is setup

View File

@@ -101,14 +101,14 @@ function M.parse_ts(root, children, bufnr)
return children return children
end end
function M.get_symbols() function M.get_symbols(bufnr)
local status, parsers = pcall(require, 'nvim-treesitter.parsers') local status, parsers = pcall(require, 'nvim-treesitter.parsers')
if not status then if not status then
return {} return {}
end end
local bufnr = 0 bufnr = bufnr or 0
local parser = parsers.get_parser(bufnr) local parser = parsers.get_parser(bufnr)

View File

@@ -73,27 +73,6 @@ function M.add_hover_highlights (bufnr, nodes)
end end
end end
---@class outline.FlatSymbolNode
---@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
---@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, ---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 -- parses each node and replaces old lines with new lines to be written for the
-- outline buffer. -- outline buffer.

View File

@@ -33,14 +33,15 @@ local your_new_opts = --[[put the cursor on this line]] {
--------------------------------------------------------------------- ---------------------------------------------------------------------
----- BEGIN SCRIPT -------------------------------------------------- ----- BEGIN SCRIPT --------------------------------------------------
--------------------------------------------------------------------- ---------------------------------------------------------------------
local newopts = {} local newopts = { symbols = {} }
if opts.symbols or opts.symbol_blacklist then if opts.symbols then
newopts.symbols = { newopts.symbols.icons = opts.symbols
icons = opts.symbols,
blacklist = opts.symbol_blacklist,
}
opts.symbols = nil opts.symbols = nil
end
if opts.symbol_blacklist then
newopts.symbols.filter = opts.symbol_blacklist
newopts.symbols.filter.exclude = true
opts.symbol_blacklist = nil opts.symbol_blacklist = nil
end end
@@ -109,7 +110,7 @@ if opts and next(opts) ~= nil then
end end
end end
for _, v in ipairs({'outline_items', 'outline_window', 'preview_window'}) do for _, v in ipairs({'outline_items', 'outline_window', 'preview_window', 'symbols'}) do
if newopts[v] and next(newopts[v]) == nil then if newopts[v] and next(newopts[v]) == nil then
newopts[v] = nil newopts[v] = nil
end end