From e38d782e767af7570963044390be7cd044c41960 Mon Sep 17 00:00:00 2001 From: disrupted Date: Sun, 22 Feb 2026 22:44:44 +0100 Subject: [PATCH 01/21] feat(treesitter): drop unnamed nodes --- lua/dropbar/sources/treesitter.lua | 47 +++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index b6be7937..5165f461 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -15,19 +15,50 @@ local function snake_to_camel(str) end ---Get short name of treesitter symbols in buffer buf +---@param text string +---@return string +local function extract_short_name(text) + return vim + .trim(vim.fn.matchstr(text, configs.opts.sources.treesitter.name_regex)) + :gsub('%s+', ' ') +end + +---@param node_type string +---@return boolean +local function is_name_like_node_type(node_type) + node_type = node_type:lower() + return node_type:find('ident', 1, true) + or node_type:find('name', 1, true) + or node_type:find('string', 1, true) + or node_type:find('symbol', 1, true) + or node_type:find('key', 1, true) +end + ---@param node TSNode ---@param buf integer buffer handler ---@return string name local function get_node_short_name(node, buf) - return ( - vim - .trim( - vim.fn.matchstr( - vim.treesitter.get_node_text(node, buf):gsub('\n', ' '), - configs.opts.sources.treesitter.name_regex + local has_named_children = false + for child in node:iter_children() do + if child:named() then + has_named_children = true + if is_name_like_node_type(child:type()) then + local name = extract_short_name( + vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') ) - ) - :gsub('%s+', ' ') + if name ~= '' then + return name + end + end + end + end + + if has_named_children then + return '' + end + + return extract_short_name( + vim.treesitter.get_node_text(node, buf):gsub('\n', ' ') ) end From 711ff3694b03ed0e78d07632dfbbd8bbcb3367d1 Mon Sep 17 00:00:00 2001 From: disrupted Date: Sun, 22 Feb 2026 23:05:35 +0100 Subject: [PATCH 02/21] feat(treesitter): is node name like identifier --- lua/dropbar/sources/treesitter.lua | 32 ++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 5165f461..e3353950 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -34,15 +34,30 @@ local function is_name_like_node_type(node_type) or node_type:find('key', 1, true) end +---@param field_name string +---@return boolean +local function is_name_like_field_name(field_name) + field_name = field_name:lower() + return field_name:find('name', 1, true) + or field_name:find('ident', 1, true) + or field_name:find('id', 1, true) + or field_name:find('key', 1, true) + or field_name:find('path', 1, true) + or field_name:find('label', 1, true) +end + ---@param node TSNode ---@param buf integer buffer handler ---@return string name local function get_node_short_name(node, buf) local has_named_children = false - for child in node:iter_children() do + local name_like_children = {} ---@type TSNode[] + + for child, field_name in node:iter_children() do if child:named() then has_named_children = true - if is_name_like_node_type(child:type()) then + + if field_name and is_name_like_field_name(field_name) then local name = extract_short_name( vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') ) @@ -50,6 +65,19 @@ local function get_node_short_name(node, buf) return name end end + + if is_name_like_node_type(child:type()) then + table.insert(name_like_children, child) + end + end + end + + for _, child in ipairs(name_like_children) do + local name = extract_short_name( + vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') + ) + if name ~= '' then + return name end end From a7fd968d70f5f4ba1aab441136cf8d3fa78a03ad Mon Sep 17 00:00:00 2001 From: disrupted Date: Sun, 22 Feb 2026 23:13:44 +0100 Subject: [PATCH 03/21] feat(treesitter): dedup adjacent symbols --- lua/dropbar/sources/treesitter.lua | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index e3353950..e4a311f6 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -112,6 +112,76 @@ local function valid_node(node, buf) and get_node_short_name(node, buf) ~= '' end +---@param a_pos { line: integer, character: integer } +---@param b_pos { line: integer, character: integer } +---@return integer +local function compare_pos(a_pos, b_pos) + if a_pos.line ~= b_pos.line then + return a_pos.line < b_pos.line and -1 or 1 + end + if a_pos.character ~= b_pos.character then + return a_pos.character < b_pos.character and -1 or 1 + end + return 0 +end + +---@param outer dropbar_symbol_t +---@param inner dropbar_symbol_t +---@return boolean +local function range_contains(outer, inner) + return compare_pos(outer.range.start, inner.range.start) <= 0 + and compare_pos(outer.range['end'], inner.range['end']) >= 0 +end + +---@param lhs dropbar_symbol_t +---@param rhs dropbar_symbol_t +---@return boolean +local function should_dedupe_adjacent(lhs, rhs) + if lhs.name ~= rhs.name or lhs.name == '' then + return false + end + + local same_start = compare_pos(lhs.range.start, rhs.range.start) == 0 + local same_end = compare_pos(lhs.range['end'], rhs.range['end']) == 0 + if not same_start and not same_end then + return false + end + + return range_contains(lhs, rhs) or range_contains(rhs, lhs) +end + +---@param symbols dropbar_symbol_t[] +---@return dropbar_symbol_t[] +local function dedupe_adjacent_symbols(symbols) + if #symbols < 2 then + return symbols + end + + local deduped = { symbols[1] } + for i = 2, #symbols do + local current = symbols[i] + local previous = deduped[#deduped] + if should_dedupe_adjacent(previous, current) then + local previous_contains_current = range_contains(previous, current) + local current_contains_previous = range_contains(current, previous) + if previous_contains_current and not current_contains_previous then + -- Keep narrower symbol when names overlap. + deduped[#deduped] = current + elseif current_contains_previous and not previous_contains_current then + -- Keep narrower symbol when names overlap. + deduped[#deduped] = previous + else + -- Equal ranges: keep the deeper (later) symbol. + deduped[#deduped] = current + end + else + table.insert(deduped, current) + end + end + + return deduped +end + ---Get treesitter node children ---@param node TSNode ---@param buf integer buffer handler @@ -174,6 +244,7 @@ local function convert(ts_node, buf, win) return bar.dropbar_symbol_t:new(setmetatable({ buf = buf, win = win, + kind = kind, name = get_node_short_name(ts_node, buf), icon = configs.opts.icons.kinds.symbols[kind], name_hl = 'DropBarKind' .. kind, @@ -249,6 +320,8 @@ local function get_symbols(buf, win, cursor) node = node:parent() end + symbols = dedupe_adjacent_symbols(symbols) + utils.bar.set_min_widths(symbols, configs.opts.sources.treesitter.min_widths) return symbols end From f786ac346149edf71d659153a528b82efbcdffe1 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 00:03:56 +0100 Subject: [PATCH 04/21] feat(treesitter): dedupe based on resolved pos --- lua/dropbar/sources/treesitter.lua | 133 ++++++++++++++++++----------- 1 file changed, 82 insertions(+), 51 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index e4a311f6..d487eb01 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -23,71 +23,81 @@ local function extract_short_name(text) :gsub('%s+', ' ') end ----@param node_type string ----@return boolean -local function is_name_like_node_type(node_type) - node_type = node_type:lower() - return node_type:find('ident', 1, true) - or node_type:find('name', 1, true) - or node_type:find('string', 1, true) - or node_type:find('symbol', 1, true) - or node_type:find('key', 1, true) -end - ----@param field_name string ----@return boolean -local function is_name_like_field_name(field_name) - field_name = field_name:lower() - return field_name:find('name', 1, true) - or field_name:find('ident', 1, true) - or field_name:find('id', 1, true) - or field_name:find('key', 1, true) - or field_name:find('path', 1, true) - or field_name:find('label', 1, true) +---@param node TSNode +---@return { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +local function get_node_range(node) + local range = { node:range() } + return { + start = { + line = range[1], + character = range[2], + }, + ['end'] = { + line = range[3], + character = range[4], + }, + } end ---@param node TSNode ----@param buf integer buffer handler ----@return string name -local function get_node_short_name(node, buf) +---@param buf integer +---@return { name: string, source_range?: { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } } +local function resolve_node_short_name(node, buf) local has_named_children = false - local name_like_children = {} ---@type TSNode[] + local named_children = {} ---@type TSNode[] for child, field_name in node:iter_children() do if child:named() then has_named_children = true + table.insert(named_children, child) - if field_name and is_name_like_field_name(field_name) then + if field_name then local name = extract_short_name( vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') ) if name ~= '' then - return name + return { + name = name, + source_range = get_node_range(child), + } end end - - if is_name_like_node_type(child:type()) then - table.insert(name_like_children, child) - end end end - for _, child in ipairs(name_like_children) do + for _, child in ipairs(named_children) do local name = extract_short_name( vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') ) if name ~= '' then - return name + return { + name = name, + source_range = get_node_range(child), + } end end if has_named_children then - return '' + return { name = '' } end - return extract_short_name( - vim.treesitter.get_node_text(node, buf):gsub('\n', ' ') - ) + local name = + extract_short_name(vim.treesitter.get_node_text(node, buf):gsub('\n', ' ')) + if name == '' then + return { name = '' } + end + + return { + name = name, + source_range = get_node_range(node), + } +end + +---@param node TSNode +---@param buf integer buffer handler +---@return string name +local function get_node_short_name(node, buf) + return resolve_node_short_name(node, buf).name end ---Get valid treesitter node type name @@ -125,6 +135,15 @@ local function compare_pos(a_pos, b_pos) return 0 end +---@param lhs_pos { line: integer, character: integer } +---@param rhs_pos { line: integer, character: integer } +---@param max_offset integer +---@return boolean +local function pos_matches_with_offset(lhs_pos, rhs_pos, max_offset) + return lhs_pos.line == rhs_pos.line + and math.abs(lhs_pos.character - rhs_pos.character) <= max_offset +end + ---@param outer dropbar_symbol_t ---@param inner dropbar_symbol_t ---@return boolean @@ -133,6 +152,14 @@ local function range_contains(outer, inner) and compare_pos(outer.range['end'], inner.range['end']) >= 0 end +---@param lhs_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@param rhs_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@return boolean +local function range_boundary_matches(lhs_range, rhs_range) + return pos_matches_with_offset(lhs_range.start, rhs_range.start, 2) + or pos_matches_with_offset(lhs_range['end'], rhs_range['end'], 2) +end + ---@param lhs dropbar_symbol_t ---@param rhs dropbar_symbol_t ---@return boolean @@ -141,6 +168,12 @@ local function should_dedupe_adjacent(lhs, rhs) return false end + if lhs.name_source and rhs.name_source then + if range_boundary_matches(lhs.name_source, rhs.name_source) then + return true + end + end + local same_start = compare_pos(lhs.range.start, rhs.range.start) == 0 local same_end = compare_pos(lhs.range['end'], rhs.range['end']) == 0 if not same_start and not same_end then @@ -236,29 +269,27 @@ end ---@param win integer window handler ---@return dropbar_symbol_t? local function convert(ts_node, buf, win) - if not valid_node(ts_node, buf) then + local short_type = get_node_short_type(ts_node) + if short_type == '' then + return nil + end + + local name_info = resolve_node_short_name(ts_node, buf) + if name_info.name == '' then return nil end - local kind = snake_to_camel(get_node_short_type(ts_node)) - local range = { ts_node:range() } + + local kind = snake_to_camel(short_type) return bar.dropbar_symbol_t:new(setmetatable({ buf = buf, win = win, kind = kind, - name = get_node_short_name(ts_node, buf), + name = name_info.name, + name_source = name_info.source_range, icon = configs.opts.icons.kinds.symbols[kind], name_hl = 'DropBarKind' .. kind, icon_hl = 'DropBarIconKind' .. kind, - range = { - start = { - line = range[1], - character = range[2], - }, - ['end'] = { - line = range[3], - character = range[4], - }, - }, + range = get_node_range(ts_node), }, { ---@param self dropbar_symbol_t ---@param k string|number From 85b8ea1ea91ed84098d0bdad93488faa2ae3b046 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 00:19:51 +0100 Subject: [PATCH 05/21] fix(treesitter): exclude child node that is not within cursor pos --- lua/dropbar/sources/treesitter.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index d487eb01..3e8d6568 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -45,6 +45,7 @@ end local function resolve_node_short_name(node, buf) local has_named_children = false local named_children = {} ---@type TSNode[] + local node_start_line = select(1, node:range()) for child, field_name in node:iter_children() do if child:named() then @@ -66,6 +67,11 @@ local function resolve_node_short_name(node, buf) end for _, child in ipairs(named_children) do + local child_start_line = select(1, child:range()) + if child_start_line ~= node_start_line then + goto continue + end + local name = extract_short_name( vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') ) @@ -75,6 +81,8 @@ local function resolve_node_short_name(node, buf) source_range = get_node_range(child), } end + + ::continue:: end if has_named_children then From 3c8398f110f847c907e8b1fbd52ba73c3fa536c5 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 00:26:31 +0100 Subject: [PATCH 06/21] refactor(treesitter): exclude anonymous nodes from name resolution --- lua/dropbar/sources/treesitter.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 3e8d6568..3b6589dd 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -43,12 +43,20 @@ end ---@param buf integer ---@return { name: string, source_range?: { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } } local function resolve_node_short_name(node, buf) + local function has_anonymous_only_children(candidate) + return candidate:child_count() > 0 and candidate:named_child_count() == 0 + end + local has_named_children = false local named_children = {} ---@type TSNode[] local node_start_line = select(1, node:range()) for child, field_name in node:iter_children() do if child:named() then + if has_anonymous_only_children(child) then + goto continue + end + has_named_children = true table.insert(named_children, child) @@ -64,6 +72,8 @@ local function resolve_node_short_name(node, buf) end end end + + ::continue:: end for _, child in ipairs(named_children) do From 2d46c09cc3c571fe56e54645fe1e6eeb87f518cc Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 00:41:56 +0100 Subject: [PATCH 07/21] feat(treesitter): collapse child if name range fully contained in parent --- lua/dropbar/sources/treesitter.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 3b6589dd..c3ae6ac2 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -178,6 +178,14 @@ local function range_boundary_matches(lhs_range, rhs_range) or pos_matches_with_offset(lhs_range['end'], rhs_range['end'], 2) end +---@param outer_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@param inner_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@return boolean +local function range_contains_range(outer_range, inner_range) + return compare_pos(outer_range.start, inner_range.start) <= 0 + and compare_pos(outer_range['end'], inner_range['end']) >= 0 +end + ---@param lhs dropbar_symbol_t ---@param rhs dropbar_symbol_t ---@return boolean @@ -212,6 +220,17 @@ local function dedupe_adjacent_symbols(symbols) for i = 2, #symbols do local current = symbols[i] local previous = deduped[#deduped] + + if + previous.name_source + and current.name_source + and range_contains_range(previous.name_source, current.name_source) + and previous.name_source.start.line == current.name_source.start.line + and previous.name_source['end'].line == current.name_source['end'].line + then + goto continue + end + if should_dedupe_adjacent(previous, current) then local previous_contains_current = range_contains(previous, current) local current_contains_previous = range_contains(current, previous) @@ -228,6 +247,8 @@ local function dedupe_adjacent_symbols(symbols) else table.insert(deduped, current) end + + ::continue:: end return deduped From 9475fc1f7796aa1b02cff002cf349a61f6b514b0 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 00:50:18 +0100 Subject: [PATCH 08/21] test(treesitter): create tests --- tests/sources/treesitter_spec.lua | 332 ++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 tests/sources/treesitter_spec.lua diff --git a/tests/sources/treesitter_spec.lua b/tests/sources/treesitter_spec.lua new file mode 100644 index 00000000..46fd1560 --- /dev/null +++ b/tests/sources/treesitter_spec.lua @@ -0,0 +1,332 @@ +---@diagnostic disable: undefined-field + +local dropbar = require('dropbar') +local source_treesitter = require('dropbar.sources.treesitter') +local stub = require('luassert.stub') + +---@param opts? { +--- type_name?: string, +--- text?: string, +--- range?: integer[], +--- named?: boolean, +--- children?: TSNode[], +--- fields?: (string|nil)[], +---} +---@return TSNode +local function ts_node(opts) + opts = opts or {} + local children = opts.children or {} + local fields = opts.fields or {} + local ts = { + _type = opts.type_name or 'identifier', + _text = opts.text or '', + _range = opts.range or { 0, 0, 0, 0 }, + _named = opts.named ~= false, + _children = children, + _fields = fields, + _parent = nil, + _index = nil, + } + + for i, child in ipairs(children) do + child._parent = ts + child._index = i + end + + ts.type = function(self) + return self._type + end + ts.range = function(self) + return unpack(self._range) + end + ts.parent = function(self) + return self._parent + end + ts.named = function(self) + return self._named + end + ts.child_count = function(self) + return #self._children + end + ts.named_child_count = function(self) + local count = 0 + for _, child in ipairs(self._children) do + if child:named() then + count = count + 1 + end + end + return count + end + ts.iter_children = function(self) + local i = 0 + return function() + i = i + 1 + local child = self._children[i] + if not child then + return nil + end + return child, self._fields[i] + end + end + ts.prev_sibling = function(self) + local parent = self._parent + if not parent or not self._index or self._index <= 1 then + return nil + end + return parent._children[self._index - 1] + end + ts.next_sibling = function(self) + local parent = self._parent + if not parent or not self._index then + return nil + end + return parent._children[self._index + 1] + end + + return ts +end + +---@param cursor_node TSNode +---@param stubs luassert.stub[] +local function stub_treesitter(cursor_node, stubs) + table.insert( + stubs, + stub(vim.treesitter, 'get_parser', function() + return true + end) + ) + table.insert( + stubs, + stub(vim.filetype, 'match', function() + return 'nickel' + end) + ) + table.insert( + stubs, + stub(vim.treesitter, 'get_node', function() + return cursor_node + end) + ) + table.insert( + stubs, + stub(vim.treesitter, 'get_node_text', function(node) + return node._text + end) + ) +end + +---@param symbols dropbar_symbol_t[] +---@return string[] +local function symbol_names(symbols) + return vim.tbl_map(function(symbol) + return symbol.name + end, symbols) +end + +describe('[source][treesitter]', function() + local stubs = {} + + before_each(function() + dropbar.setup({ + bar = { + sources = { + source_treesitter, + }, + }, + sources = { + treesitter = { + valid_types = { + 'classProperty', + 'modifier', + 'pair', + 'identifier', + 'class', + 'function', + }, + name_regex = '[A-Za-z_][A-Za-z0-9_.]*', + }, + }, + }) + end) + + after_each(function() + for _, s in ipairs(stubs) do + s:revert() + end + stubs = {} + end) + + it('resolves names from named children, not anonymous tokens', function() + local local_token = ts_node({ + type_name = 'string', + text = 'local', + range = { 0, 0, 0, 5 }, + named = false, + }) + local modifier = ts_node({ + type_name = 'modifier', + text = 'local', + range = { 0, 0, 0, 5 }, + children = { local_token }, + fields = { nil }, + }) + local identifier = ts_node({ + type_name = 'identifier', + text = 'myField', + range = { 0, 6, 0, 13 }, + }) + local class_property = ts_node({ + type_name = 'classProperty', + text = 'local myField', + range = { 0, 0, 0, 13 }, + children = { modifier, identifier }, + fields = { nil, 'name' }, + }) + + stub_treesitter(class_property, stubs) + + local symbols = source_treesitter.get_symbols( + vim.api.nvim_get_current_buf(), + vim.api.nvim_get_current_win(), + { 1, 7 } + ) + + assert.are.same({ 'myField' }, symbol_names(symbols)) + end) + + it( + 'collapses same-line contained path segments into parent breadcrumb', + function() + local path = ts_node({ + type_name = 'identifier', + text = 'grammar.source.git', + range = { 1, 2, 1, 20 }, + }) + local grammar = ts_node({ + type_name = 'identifier', + text = 'grammar', + range = { 1, 2, 1, 9 }, + }) + local source = ts_node({ + type_name = 'identifier', + text = 'source', + range = { 1, 10, 1, 16 }, + }) + grammar._parent = path + source._parent = grammar + + stub_treesitter(source, stubs) + + local symbols = source_treesitter.get_symbols( + vim.api.nvim_get_current_buf(), + vim.api.nvim_get_current_win(), + { 2, 12 } + ) + + assert.are.same({ 'grammar.source.git' }, symbol_names(symbols)) + end + ) + + it( + 'keeps child symbol when parent and child names are on different lines', + function() + local path = ts_node({ + type_name = 'identifier', + text = 'grammar.source.git', + range = { 1, 2, 1, 20 }, + }) + local rev = ts_node({ + type_name = 'identifier', + text = 'rev', + range = { 2, 4, 2, 7 }, + }) + rev._parent = path + + stub_treesitter(rev, stubs) + + local symbols = source_treesitter.get_symbols( + vim.api.nvim_get_current_buf(), + vim.api.nvim_get_current_win(), + { 3, 5 } + ) + + assert.are.same({ 'grammar.source.git', 'rev' }, symbol_names(symbols)) + end + ) + + it('deduplicates wrapper symbols with identical names', function() + local settings_pair = ts_node({ + type_name = 'pair', + text = 'settings = { ... }', + range = { 0, 2, 8, 0 }, + }) + local settings_id = ts_node({ + type_name = 'identifier', + text = 'settings', + range = { 0, 2, 0, 10 }, + }) + local server_pair = ts_node({ + type_name = 'pair', + text = 'server = { ... }', + range = { 1, 4, 7, 2 }, + }) + local server_id = ts_node({ + type_name = 'identifier', + text = 'server', + range = { 1, 4, 1, 10 }, + }) + local host_id = ts_node({ + type_name = 'identifier', + text = 'host', + range = { 2, 6, 2, 10 }, + }) + + settings_id._parent = settings_pair + server_pair._parent = settings_id + server_id._parent = server_pair + host_id._parent = server_id + + stub_treesitter(host_id, stubs) + + local symbols = source_treesitter.get_symbols( + vim.api.nvim_get_current_buf(), + vim.api.nvim_get_current_win(), + { 3, 8 } + ) + + assert.are.same({ 'settings', 'server', 'host' }, symbol_names(symbols)) + end) + + it( + 'does not deduplicate non-wrapper symbols that just share names', + function() + local class_foo = ts_node({ + type_name = 'class', + text = 'Foo', + range = { 0, 0, 10, 0 }, + }) + local function_foo = ts_node({ + type_name = 'function', + text = 'Foo', + range = { 2, 2, 4, 2 }, + }) + local identifier = ts_node({ + type_name = 'identifier', + text = 'x', + range = { 3, 4, 3, 5 }, + }) + function_foo._parent = class_foo + identifier._parent = function_foo + + stub_treesitter(identifier, stubs) + + local symbols = source_treesitter.get_symbols( + vim.api.nvim_get_current_buf(), + vim.api.nvim_get_current_win(), + { 4, 5 } + ) + + assert.are.same({ 'Foo', 'Foo', 'x' }, symbol_names(symbols)) + end + ) +end) From e59c3a15fcc39196f3238c325763aa0fe82973e8 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 20:18:49 +0100 Subject: [PATCH 09/21] fix(treesitter): prefer narrower cross-line contained symbol --- lua/dropbar/sources/treesitter.lua | 22 +++++++++++++++++++--- tests/sources/treesitter_spec.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index c3ae6ac2..cc034603 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -225,10 +225,26 @@ local function dedupe_adjacent_symbols(symbols) previous.name_source and current.name_source and range_contains_range(previous.name_source, current.name_source) - and previous.name_source.start.line == current.name_source.start.line - and previous.name_source['end'].line == current.name_source['end'].line then - goto continue + local same_start = + compare_pos(previous.name_source.start, current.name_source.start) == 0 + local current_ends_earlier = + compare_pos(current.name_source['end'], previous.name_source['end']) < 0 + if + same_start + and current_ends_earlier + and previous.name_source['end'].line ~= current.name_source['end'].line + then + deduped[#deduped] = current + goto continue + end + + if + previous.name_source.start.line == current.name_source.start.line + and previous.name_source['end'].line == current.name_source['end'].line + then + goto continue + end end if should_dedupe_adjacent(previous, current) then diff --git a/tests/sources/treesitter_spec.lua b/tests/sources/treesitter_spec.lua index 46fd1560..e1455b1f 100644 --- a/tests/sources/treesitter_spec.lua +++ b/tests/sources/treesitter_spec.lua @@ -254,6 +254,33 @@ describe('[source][treesitter]', function() end ) + it( + 'prefers narrower name when broader parent starts same place across lines', + function() + local broad = ts_node({ + type_name = 'identifier', + text = 'self.get_base_types_for_class', + range = { 0, 17, 2, 33 }, + }) + local self_symbol = ts_node({ + type_name = 'identifier', + text = 'self', + range = { 0, 17, 1, 29 }, + }) + self_symbol._parent = broad + + stub_treesitter(self_symbol, stubs) + + local symbols = source_treesitter.get_symbols( + vim.api.nvim_get_current_buf(), + vim.api.nvim_get_current_win(), + { 1, 18 } + ) + + assert.are.same({ 'self' }, symbol_names(symbols)) + end + ) + it('deduplicates wrapper symbols with identical names', function() local settings_pair = ts_node({ type_name = 'pair', From bac7f9f551764baa42c8bbe11bfec7cf0074e570 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 20:29:39 +0100 Subject: [PATCH 10/21] style: format --- lua/dropbar/sources/treesitter.lua | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index cc034603..62c9d4ac 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -226,14 +226,19 @@ local function dedupe_adjacent_symbols(symbols) and current.name_source and range_contains_range(previous.name_source, current.name_source) then - local same_start = - compare_pos(previous.name_source.start, current.name_source.start) == 0 - local current_ends_earlier = - compare_pos(current.name_source['end'], previous.name_source['end']) < 0 + local same_start = compare_pos( + previous.name_source.start, + current.name_source.start + ) == 0 + local current_ends_earlier = compare_pos( + current.name_source['end'], + previous.name_source['end'] + ) < 0 if same_start and current_ends_earlier - and previous.name_source['end'].line ~= current.name_source['end'].line + and previous.name_source['end'].line + ~= current.name_source['end'].line then deduped[#deduped] = current goto continue @@ -241,7 +246,8 @@ local function dedupe_adjacent_symbols(symbols) if previous.name_source.start.line == current.name_source.start.line - and previous.name_source['end'].line == current.name_source['end'].line + and previous.name_source['end'].line + == current.name_source['end'].line then goto continue end From 4d664e3b48a3f862d5c1f9acd6819bfce8f6d0ab Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 22:49:56 +0100 Subject: [PATCH 11/21] refactor: use `vim.treesitter` --- lua/dropbar/sources/treesitter.lua | 53 +++++++++++++++++++----------- tests/sources/treesitter_spec.lua | 26 +++++++++++++++ 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 62c9d4ac..6423d649 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -2,6 +2,9 @@ local configs = require('dropbar.configs') local bar = require('dropbar.bar') local utils = require('dropbar.utils') +---@alias dropbar_ts_pos { line: integer, character: integer } +---@alias dropbar_ts_range { start: dropbar_ts_pos, ['end']: dropbar_ts_pos } + ---Convert a snake_case string to camelCase ---@param str string? ---@return string? @@ -24,24 +27,25 @@ local function extract_short_name(text) end ---@param node TSNode ----@return { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@return dropbar_ts_range local function get_node_range(node) - local range = { node:range() } + local start_line, start_col, end_line, end_col = + vim.treesitter.get_node_range(node) return { start = { - line = range[1], - character = range[2], + line = start_line, + character = start_col, }, ['end'] = { - line = range[3], - character = range[4], + line = end_line, + character = end_col, }, } end ---@param node TSNode ---@param buf integer ----@return { name: string, source_range?: { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } } +---@return { name: string, source_range?: dropbar_ts_range } local function resolve_node_short_name(node, buf) local function has_anonymous_only_children(candidate) return candidate:child_count() > 0 and candidate:named_child_count() == 0 @@ -49,7 +53,7 @@ local function resolve_node_short_name(node, buf) local has_named_children = false local named_children = {} ---@type TSNode[] - local node_start_line = select(1, node:range()) + local node_start_line = vim.treesitter.get_node_range(node) for child, field_name in node:iter_children() do if child:named() then @@ -77,7 +81,7 @@ local function resolve_node_short_name(node, buf) end for _, child in ipairs(named_children) do - local child_start_line = select(1, child:range()) + local child_start_line = vim.treesitter.get_node_range(child) if child_start_line ~= node_start_line then goto continue end @@ -140,8 +144,8 @@ local function valid_node(node, buf) and get_node_short_name(node, buf) ~= '' end ----@param a_pos { line: integer, character: integer } ----@param b_pos { line: integer, character: integer } +---@param a_pos dropbar_ts_pos +---@param b_pos dropbar_ts_pos ---@return integer local function compare_pos(a_pos, b_pos) if a_pos.line ~= b_pos.line then @@ -153,8 +157,19 @@ local function compare_pos(a_pos, b_pos) return 0 end ----@param lhs_pos { line: integer, character: integer } ----@param rhs_pos { line: integer, character: integer } +---@param range dropbar_ts_range +---@return integer[] +local function to_range4(range) + return { + range.start.line, + range.start.character, + range['end'].line, + range['end'].character, + } +end + +---@param lhs_pos dropbar_ts_pos +---@param rhs_pos dropbar_ts_pos ---@param max_offset integer ---@return boolean local function pos_matches_with_offset(lhs_pos, rhs_pos, max_offset) @@ -166,20 +181,19 @@ end ---@param inner dropbar_symbol_t ---@return boolean local function range_contains(outer, inner) - return compare_pos(outer.range.start, inner.range.start) <= 0 - and compare_pos(outer.range['end'], inner.range['end']) >= 0 + return vim.treesitter.node_contains(outer.ts_node, to_range4(inner.range)) end ----@param lhs_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } ----@param rhs_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@param lhs_range dropbar_ts_range +---@param rhs_range dropbar_ts_range ---@return boolean local function range_boundary_matches(lhs_range, rhs_range) return pos_matches_with_offset(lhs_range.start, rhs_range.start, 2) or pos_matches_with_offset(lhs_range['end'], rhs_range['end'], 2) end ----@param outer_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } ----@param inner_range { start: { line: integer, character: integer }, ['end']: { line: integer, character: integer } } +---@param outer_range dropbar_ts_range +---@param inner_range dropbar_ts_range ---@return boolean local function range_contains_range(outer_range, inner_range) return compare_pos(outer_range.start, inner_range.start) <= 0 @@ -344,6 +358,7 @@ local function convert(ts_node, buf, win) return bar.dropbar_symbol_t:new(setmetatable({ buf = buf, win = win, + ts_node = ts_node, kind = kind, name = name_info.name, name_source = name_info.source_range, diff --git a/tests/sources/treesitter_spec.lua b/tests/sources/treesitter_spec.lua index e1455b1f..854930ae 100644 --- a/tests/sources/treesitter_spec.lua +++ b/tests/sources/treesitter_spec.lua @@ -113,6 +113,32 @@ local function stub_treesitter(cursor_node, stubs) return node._text end) ) + table.insert( + stubs, + stub(vim.treesitter, 'get_node_range', function(node_or_range) + if type(node_or_range) == 'table' and node_or_range._range then + return unpack(node_or_range._range) + end + return unpack(node_or_range) + end) + ) + table.insert( + stubs, + stub(vim.treesitter, 'node_contains', function(node, range) + local node_start_row, node_start_col, node_end_row, node_end_col = + unpack(node._range) + local range_start_row, range_start_col, range_end_row, range_end_col = + unpack(range) + local node_starts_before_range = node_start_row < range_start_row + or ( + node_start_row == range_start_row + and node_start_col <= range_start_col + ) + local node_ends_after_range = node_end_row > range_end_row + or (node_end_row == range_end_row and node_end_col >= range_end_col) + return node_starts_before_range and node_ends_after_range + end) + ) end ---@param symbols dropbar_symbol_t[] From 9cb766fe770e9dc43aa1eabbc98e9bbd792732d6 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 23:04:55 +0100 Subject: [PATCH 12/21] refactor: extract shared symbol resolver --- lua/dropbar/sources/treesitter.lua | 66 ++++++++++++++++++------------ 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 6423d649..454edb90 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -115,13 +115,6 @@ local function resolve_node_short_name(node, buf) } end ----@param node TSNode ----@param buf integer buffer handler ----@return string name -local function get_node_short_name(node, buf) - return resolve_node_short_name(node, buf).name -end - ---Get valid treesitter node type name ---@param node TSNode ---@return string type_name @@ -135,13 +128,38 @@ local function get_node_short_type(node) return '' end +---@class dropbar_ts_symbol_info +---@field short_type string +---@field kind string +---@field name_info { name: string, source_range?: dropbar_ts_range } + +---@param node TSNode +---@param buf integer buffer handler +---@return dropbar_ts_symbol_info? +local function resolve_symbol_info(node, buf) + local short_type = get_node_short_type(node) + if short_type == '' then + return nil + end + + local name_info = resolve_node_short_name(node, buf) + if name_info.name == '' then + return nil + end + + return { + short_type = short_type, + kind = snake_to_camel(short_type), + name_info = name_info, + } +end + ---Check if treesitter node is valid ---@param node TSNode ---@param buf integer buffer handler ---@return boolean local function valid_node(node, buf) - return get_node_short_type(node) ~= '' - and get_node_short_name(node, buf) ~= '' + return resolve_symbol_info(node, buf) ~= nil end ---@param a_pos dropbar_ts_pos @@ -342,29 +360,24 @@ end ---@param ts_node TSNode ---@param buf integer buffer handler ---@param win integer window handler +---@param symbol_info? dropbar_ts_symbol_info ---@return dropbar_symbol_t? -local function convert(ts_node, buf, win) - local short_type = get_node_short_type(ts_node) - if short_type == '' then - return nil - end - - local name_info = resolve_node_short_name(ts_node, buf) - if name_info.name == '' then +local function convert(ts_node, buf, win, symbol_info) + symbol_info = symbol_info or resolve_symbol_info(ts_node, buf) + if not symbol_info then return nil end - local kind = snake_to_camel(short_type) return bar.dropbar_symbol_t:new(setmetatable({ buf = buf, win = win, ts_node = ts_node, - kind = kind, - name = name_info.name, - name_source = name_info.source_range, - icon = configs.opts.icons.kinds.symbols[kind], - name_hl = 'DropBarKind' .. kind, - icon_hl = 'DropBarIconKind' .. kind, + kind = symbol_info.kind, + name = symbol_info.name_info.name, + name_source = symbol_info.name_info.source_range, + icon = configs.opts.icons.kinds.symbols[symbol_info.kind], + name_hl = 'DropBarKind' .. symbol_info.kind, + icon_hl = 'DropBarIconKind' .. symbol_info.kind, range = get_node_range(ts_node), }, { ---@param self dropbar_symbol_t @@ -421,8 +434,9 @@ local function get_symbols(buf, win, cursor) }) while node and #symbols < configs.opts.sources.treesitter.max_depth do - if valid_node(node, buf) then - table.insert(symbols, 1, convert(node, buf, win)) + local symbol_info = resolve_symbol_info(node, buf) + if symbol_info then + table.insert(symbols, 1, convert(node, buf, win, symbol_info)) end node = node:parent() end From f039633bec452006de50b4fd91070a1c2a1f91c3 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 23:13:32 +0100 Subject: [PATCH 13/21] perf: cache symbols per run --- lua/dropbar/sources/treesitter.lua | 124 +++++++++++++++++++---------- 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 454edb90..8c4b83fa 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -5,6 +5,8 @@ local utils = require('dropbar.utils') ---@alias dropbar_ts_pos { line: integer, character: integer } ---@alias dropbar_ts_range { start: dropbar_ts_pos, ['end']: dropbar_ts_pos } +local cache_nil = {} + ---Convert a snake_case string to camelCase ---@param str string? ---@return string? @@ -26,6 +28,35 @@ local function extract_short_name(text) :gsub('%s+', ' ') end +---@return table +local function create_symbol_cache() + return { + symbol_info = setmetatable({}, { __mode = 'k' }), + short_name = setmetatable({}, { __mode = 'k' }), + } +end + +---@param node TSNode +---@param buf integer +---@param cache table +---@return string? +local function get_short_name_for_node(node, buf, cache) + local cached = cache.short_name[node] + if cached ~= nil then + return cached == cache_nil and nil or cached + end + + local name = + extract_short_name(vim.treesitter.get_node_text(node, buf):gsub('\n', ' ')) + if name == '' then + cache.short_name[node] = cache_nil + return nil + end + + cache.short_name[node] = name + return name +end + ---@param node TSNode ---@return dropbar_ts_range local function get_node_range(node) @@ -45,8 +76,9 @@ end ---@param node TSNode ---@param buf integer ----@return { name: string, source_range?: dropbar_ts_range } -local function resolve_node_short_name(node, buf) +---@param cache table +---@return { name: string, source_range?: dropbar_ts_range }? +local function resolve_node_short_name(node, buf, cache) local function has_anonymous_only_children(candidate) return candidate:child_count() > 0 and candidate:named_child_count() == 0 end @@ -65,10 +97,8 @@ local function resolve_node_short_name(node, buf) table.insert(named_children, child) if field_name then - local name = extract_short_name( - vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') - ) - if name ~= '' then + local name = get_short_name_for_node(child, buf, cache) + if name then return { name = name, source_range = get_node_range(child), @@ -86,10 +116,8 @@ local function resolve_node_short_name(node, buf) goto continue end - local name = extract_short_name( - vim.treesitter.get_node_text(child, buf):gsub('\n', ' ') - ) - if name ~= '' then + local name = get_short_name_for_node(child, buf, cache) + if name then return { name = name, source_range = get_node_range(child), @@ -100,13 +128,12 @@ local function resolve_node_short_name(node, buf) end if has_named_children then - return { name = '' } + return nil end - local name = - extract_short_name(vim.treesitter.get_node_text(node, buf):gsub('\n', ' ')) - if name == '' then - return { name = '' } + local name = get_short_name_for_node(node, buf, cache) + if not name then + return nil end return { @@ -135,31 +162,42 @@ end ---@param node TSNode ---@param buf integer buffer handler +---@param cache table ---@return dropbar_ts_symbol_info? -local function resolve_symbol_info(node, buf) +local function resolve_symbol_info(node, buf, cache) + local cached = cache.symbol_info[node] + if cached ~= nil then + return cached == cache_nil and nil or cached + end + local short_type = get_node_short_type(node) if short_type == '' then + cache.symbol_info[node] = cache_nil return nil end - local name_info = resolve_node_short_name(node, buf) - if name_info.name == '' then + local name_info = resolve_node_short_name(node, buf, cache) + if not name_info then + cache.symbol_info[node] = cache_nil return nil end - return { + local symbol_info = { short_type = short_type, kind = snake_to_camel(short_type), name_info = name_info, } + cache.symbol_info[node] = symbol_info + return symbol_info end ---Check if treesitter node is valid ---@param node TSNode ---@param buf integer buffer handler +---@param cache table ---@return boolean -local function valid_node(node, buf) - return resolve_symbol_info(node, buf) ~= nil +local function valid_node(node, buf, cache) + return resolve_symbol_info(node, buf, cache) ~= nil end ---@param a_pos dropbar_ts_pos @@ -311,14 +349,15 @@ end ---Get treesitter node children ---@param node TSNode ---@param buf integer buffer handler +---@param cache table ---@return TSNode[] children -local function get_node_children(node, buf) +local function get_node_children(node, buf, cache) local children = {} for child in node:iter_children() do - if valid_node(child, buf) then + if valid_node(child, buf, cache) then table.insert(children, child) else - vim.list_extend(children, get_node_children(child, buf)) + vim.list_extend(children, get_node_children(child, buf, cache)) end end return children @@ -327,17 +366,19 @@ end ---Get treesitter node siblings ---@param node TSNode ---@param buf integer buffer handler +---@param cache table ---@return TSNode[] siblings ---@return integer idx index of the node in its siblings -local function get_node_siblings(node, buf) +local function get_node_siblings(node, buf, cache) local siblings = {} local current = node ---@type TSNode? while current do - if valid_node(current, buf) then + if valid_node(current, buf, cache) then table.insert(siblings, 1, current) else - siblings = vim.list_extend(get_node_children(current, buf), siblings) + siblings = + vim.list_extend(get_node_children(current, buf, cache), siblings) end current = current:prev_sibling() end @@ -345,10 +386,10 @@ local function get_node_siblings(node, buf) current = node:next_sibling() while current do - if valid_node(current, buf) then + if valid_node(current, buf, cache) then table.insert(siblings, current) else - vim.list_extend(siblings, get_node_children(current, buf)) + vim.list_extend(siblings, get_node_children(current, buf, cache)) end current = current:next_sibling() end @@ -360,10 +401,11 @@ end ---@param ts_node TSNode ---@param buf integer buffer handler ---@param win integer window handler +---@param cache table ---@param symbol_info? dropbar_ts_symbol_info ---@return dropbar_symbol_t? -local function convert(ts_node, buf, win, symbol_info) - symbol_info = symbol_info or resolve_symbol_info(ts_node, buf) +local function convert(ts_node, buf, win, cache, symbol_info) + symbol_info = symbol_info or resolve_symbol_info(ts_node, buf, cache) if not symbol_info then return nil end @@ -385,15 +427,15 @@ local function convert(ts_node, buf, win, symbol_info) __index = function(self, k) if k == 'children' then self.children = vim.tbl_map(function(child) - return convert(child, buf, win) - end, get_node_children(ts_node, buf)) + return convert(child, buf, win, cache) + end, get_node_children(ts_node, buf, cache)) return self.children end if k == 'siblings' or k == 'sibling_idx' then - local siblings, idx = get_node_siblings(ts_node, buf) + local siblings, idx = get_node_siblings(ts_node, buf, cache) self.siblings = vim.tbl_map(function(sibling) - return convert(sibling, buf, win) + return convert(sibling, buf, win, cache) end, siblings) self.sibling_idx = idx return self[k] @@ -421,22 +463,24 @@ local function get_symbols(buf, win, cursor) end local symbols = {} ---@type dropbar_symbol_t[] + local cache = create_symbol_cache() + local mode = vim.api.nvim_get_mode().mode + local is_insert_mode = mode:sub(1, 1) == 'i' + local col_offset = cursor[2] >= 1 and is_insert_mode and 1 or 0 -- Prevent errors when getting node from filetypes without a parser local node = vim.F.npcall(vim.treesitter.get_node, { - ft = vim.filetype.match({ buf = buf }), bufnr = buf, pos = { cursor[1] - 1, - cursor[2] - - (cursor[2] >= 1 and vim.startswith(vim.fn.mode(), 'i') and 1 or 0), + cursor[2] - col_offset, }, }) while node and #symbols < configs.opts.sources.treesitter.max_depth do - local symbol_info = resolve_symbol_info(node, buf) + local symbol_info = resolve_symbol_info(node, buf, cache) if symbol_info then - table.insert(symbols, 1, convert(node, buf, win, symbol_info)) + table.insert(symbols, 1, convert(node, buf, win, cache, symbol_info)) end node = node:parent() end From b42ce900159aa9a9175212732c5cfed82e8fc7cd Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 23 Feb 2026 23:22:50 +0100 Subject: [PATCH 14/21] refactor: reduce redundant containment checks --- lua/dropbar/sources/treesitter.lua | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 8c4b83fa..f7e1f690 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -259,24 +259,34 @@ end ---@param lhs dropbar_symbol_t ---@param rhs dropbar_symbol_t ---@return boolean +---@return boolean lhs_contains_rhs +---@return boolean rhs_contains_lhs local function should_dedupe_adjacent(lhs, rhs) if lhs.name ~= rhs.name or lhs.name == '' then - return false + return false, false, false end + local lhs_contains_rhs, rhs_contains_lhs + if lhs.name_source and rhs.name_source then if range_boundary_matches(lhs.name_source, rhs.name_source) then - return true + lhs_contains_rhs = range_contains(lhs, rhs) + rhs_contains_lhs = range_contains(rhs, lhs) + return true, lhs_contains_rhs, rhs_contains_lhs end end local same_start = compare_pos(lhs.range.start, rhs.range.start) == 0 local same_end = compare_pos(lhs.range['end'], rhs.range['end']) == 0 if not same_start and not same_end then - return false + return false, false, false end - return range_contains(lhs, rhs) or range_contains(rhs, lhs) + lhs_contains_rhs = range_contains(lhs, rhs) + rhs_contains_lhs = range_contains(rhs, lhs) + return lhs_contains_rhs or rhs_contains_lhs, + lhs_contains_rhs, + rhs_contains_lhs end ---@param symbols dropbar_symbol_t[] @@ -323,9 +333,9 @@ local function dedupe_adjacent_symbols(symbols) end end - if should_dedupe_adjacent(previous, current) then - local previous_contains_current = range_contains(previous, current) - local current_contains_previous = range_contains(current, previous) + local should_dedupe, previous_contains_current, current_contains_previous = + should_dedupe_adjacent(previous, current) + if should_dedupe then if previous_contains_current and not current_contains_previous then -- Keep narrower symbol when names overlap. deduped[#deduped] = current From 82b845da9b0e8a659ddfa5c4f91ec6e267f3fc76 Mon Sep 17 00:00:00 2001 From: bekaboo Date: Fri, 27 Feb 2026 21:59:15 -0800 Subject: [PATCH 15/21] refactor(treesitter): merge `extract_short_name` and `get_short_name_for_node` --- lua/dropbar/sources/treesitter.lua | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index f7e1f690..fb3d624f 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -19,15 +19,6 @@ local function snake_to_camel(str) ) end ----Get short name of treesitter symbols in buffer buf ----@param text string ----@return string -local function extract_short_name(text) - return vim - .trim(vim.fn.matchstr(text, configs.opts.sources.treesitter.name_regex)) - :gsub('%s+', ' ') -end - ---@return table local function create_symbol_cache() return { @@ -36,18 +27,25 @@ local function create_symbol_cache() } end +---Get short name of treesitter symbols in buffer buf ---@param node TSNode ---@param buf integer ---@param cache table ---@return string? -local function get_short_name_for_node(node, buf, cache) +local function get_node_short_name(node, buf, cache) local cached = cache.short_name[node] if cached ~= nil then return cached == cache_nil and nil or cached end - local name = - extract_short_name(vim.treesitter.get_node_text(node, buf):gsub('\n', ' ')) + local name = vim + .trim( + vim.fn.matchstr( + vim.treesitter.get_node_text(node, buf):gsub('\n', ' '), + configs.opts.sources.treesitter.name_regex + ) + ) + :gsub('%s+', ' ') if name == '' then cache.short_name[node] = cache_nil return nil @@ -97,7 +95,7 @@ local function resolve_node_short_name(node, buf, cache) table.insert(named_children, child) if field_name then - local name = get_short_name_for_node(child, buf, cache) + local name = get_node_short_name(child, buf, cache) if name then return { name = name, @@ -116,7 +114,7 @@ local function resolve_node_short_name(node, buf, cache) goto continue end - local name = get_short_name_for_node(child, buf, cache) + local name = get_node_short_name(child, buf, cache) if name then return { name = name, @@ -131,7 +129,7 @@ local function resolve_node_short_name(node, buf, cache) return nil end - local name = get_short_name_for_node(node, buf, cache) + local name = get_node_short_name(node, buf, cache) if not name then return nil end From d0224e89a13afe86221943f82d0547156ae47ff9 Mon Sep 17 00:00:00 2001 From: bekaboo Date: Fri, 27 Feb 2026 22:13:33 -0800 Subject: [PATCH 16/21] refactor(treesitter): extract and rename `has_anonymous_only_children` --- lua/dropbar/sources/treesitter.lua | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index fb3d624f..61083fb0 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -72,22 +72,28 @@ local function get_node_range(node) } end +---Returns true if the node has at least one child and none of its children are +---named treesitter nodes i.e. all children are anonymous +--- +---By heuristic such nodes can be skipped when collecting symbols +---@param node TSNode +---@return boolean +local function has_only_anonymous_children(node) + return node:child_count() > 0 and node:named_child_count() == 0 +end + ---@param node TSNode ---@param buf integer ---@param cache table ---@return { name: string, source_range?: dropbar_ts_range }? local function resolve_node_short_name(node, buf, cache) - local function has_anonymous_only_children(candidate) - return candidate:child_count() > 0 and candidate:named_child_count() == 0 - end - local has_named_children = false local named_children = {} ---@type TSNode[] local node_start_line = vim.treesitter.get_node_range(node) for child, field_name in node:iter_children() do if child:named() then - if has_anonymous_only_children(child) then + if has_only_anonymous_children(child) then goto continue end From 3b21f4971e0c95e7489350a630caa50ab6b049fa Mon Sep 17 00:00:00 2001 From: bekaboo Date: Fri, 27 Feb 2026 23:00:41 -0800 Subject: [PATCH 17/21] refactor(treesitter): define class `dropbar_ts_cache_t` --- lua/dropbar/sources/treesitter.lua | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 61083fb0..335ae02d 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -19,21 +19,25 @@ local function snake_to_camel(str) ) end ----@return table +---@class dropbar_ts_cache_t +---@field symbol_info table +---@field short_names table + +---@return dropbar_ts_cache_t local function create_symbol_cache() return { symbol_info = setmetatable({}, { __mode = 'k' }), - short_name = setmetatable({}, { __mode = 'k' }), + short_names = setmetatable({}, { __mode = 'k' }), } end ---Get short name of treesitter symbols in buffer buf ---@param node TSNode ---@param buf integer ----@param cache table +---@param cache dropbar_ts_cache_t ---@return string? local function get_node_short_name(node, buf, cache) - local cached = cache.short_name[node] + local cached = cache.short_names[node] if cached ~= nil then return cached == cache_nil and nil or cached end @@ -47,11 +51,11 @@ local function get_node_short_name(node, buf, cache) ) :gsub('%s+', ' ') if name == '' then - cache.short_name[node] = cache_nil + cache.short_names[node] = cache_nil return nil end - cache.short_name[node] = name + cache.short_names[node] = name return name end @@ -84,7 +88,7 @@ end ---@param node TSNode ---@param buf integer ----@param cache table +---@param cache dropbar_ts_cache_t ---@return { name: string, source_range?: dropbar_ts_range }? local function resolve_node_short_name(node, buf, cache) local has_named_children = false @@ -166,7 +170,7 @@ end ---@param node TSNode ---@param buf integer buffer handler ----@param cache table +---@param cache dropbar_ts_cache_t ---@return dropbar_ts_symbol_info? local function resolve_symbol_info(node, buf, cache) local cached = cache.symbol_info[node] @@ -198,7 +202,7 @@ end ---Check if treesitter node is valid ---@param node TSNode ---@param buf integer buffer handler ----@param cache table +---@param cache dropbar_ts_cache_t ---@return boolean local function valid_node(node, buf, cache) return resolve_symbol_info(node, buf, cache) ~= nil @@ -363,7 +367,7 @@ end ---Get treesitter node children ---@param node TSNode ---@param buf integer buffer handler ----@param cache table +---@param cache dropbar_ts_cache_t ---@return TSNode[] children local function get_node_children(node, buf, cache) local children = {} @@ -380,7 +384,7 @@ end ---Get treesitter node siblings ---@param node TSNode ---@param buf integer buffer handler ----@param cache table +---@param cache dropbar_ts_cache_t ---@return TSNode[] siblings ---@return integer idx index of the node in its siblings local function get_node_siblings(node, buf, cache) @@ -415,7 +419,7 @@ end ---@param ts_node TSNode ---@param buf integer buffer handler ---@param win integer window handler ----@param cache table +---@param cache dropbar_ts_cache_t ---@param symbol_info? dropbar_ts_symbol_info ---@return dropbar_symbol_t? local function convert(ts_node, buf, win, cache, symbol_info) From a394a5cc3ee52b96989e08e95ee402e3f78f3438 Mon Sep 17 00:00:00 2001 From: bekaboo Date: Fri, 27 Feb 2026 23:05:53 -0800 Subject: [PATCH 18/21] refactor(treesitter): use `false` for invalid node cache val --- lua/dropbar/sources/treesitter.lua | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 335ae02d..a316b64a 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -5,8 +5,6 @@ local utils = require('dropbar.utils') ---@alias dropbar_ts_pos { line: integer, character: integer } ---@alias dropbar_ts_range { start: dropbar_ts_pos, ['end']: dropbar_ts_pos } -local cache_nil = {} - ---Convert a snake_case string to camelCase ---@param str string? ---@return string? @@ -39,7 +37,7 @@ end local function get_node_short_name(node, buf, cache) local cached = cache.short_names[node] if cached ~= nil then - return cached == cache_nil and nil or cached + return cached or nil end local name = vim @@ -51,7 +49,7 @@ local function get_node_short_name(node, buf, cache) ) :gsub('%s+', ' ') if name == '' then - cache.short_names[node] = cache_nil + cache.short_names[node] = false return nil end @@ -175,18 +173,18 @@ end local function resolve_symbol_info(node, buf, cache) local cached = cache.symbol_info[node] if cached ~= nil then - return cached == cache_nil and nil or cached + return cached or nil end local short_type = get_node_short_type(node) if short_type == '' then - cache.symbol_info[node] = cache_nil + cache.symbol_info[node] = false return nil end local name_info = resolve_node_short_name(node, buf, cache) if not name_info then - cache.symbol_info[node] = cache_nil + cache.symbol_info[node] = false return nil end From f6c743c4b4882ef1fd3a4417f3c527ce6d06b0e1 Mon Sep 17 00:00:00 2001 From: bekaboo Date: Sat, 28 Feb 2026 20:22:14 -0800 Subject: [PATCH 19/21] refactor(treesitter): extract `utils.range` --- lua/dropbar/bar.lua | 2 +- lua/dropbar/sources/lsp.lua | 106 +++++++---------------------- lua/dropbar/sources/treesitter.lua | 86 ++++++++--------------- lua/dropbar/utils/init.lua | 1 + lua/dropbar/utils/pos.lua | 18 +++++ lua/dropbar/utils/range.lua | 83 ++++++++++++++++++++++ 6 files changed, 159 insertions(+), 137 deletions(-) create mode 100644 lua/dropbar/utils/pos.lua create mode 100644 lua/dropbar/utils/range.lua diff --git a/lua/dropbar/bar.lua b/lua/dropbar/bar.lua index d00b46e4..25e1cba6 100644 --- a/lua/dropbar/bar.lua +++ b/lua/dropbar/bar.lua @@ -11,7 +11,7 @@ local function str_sanitize(str) return str and vim.gsplit(str, '\n')() end ----@alias dropbar_symbol_range_t lsp_range_t +---@alias dropbar_symbol_range_t dropbar_range_t ---Symbol in dropbar, basic element of `dropbar_t` and ---`dropbar_menu_entry_t` diff --git a/lua/dropbar/sources/lsp.lua b/lua/dropbar/sources/lsp.lua index 4c3fdd05..2f8d0baf 100644 --- a/lua/dropbar/sources/lsp.lua +++ b/lua/dropbar/sources/lsp.lua @@ -4,7 +4,7 @@ local utils = require('dropbar.utils') local groupid = vim.api.nvim_create_augroup('dropbar.sources.lsp', {}) local initialized = false ----@type table +---@type table local lsp_buf_symbols = {} setmetatable(lsp_buf_symbols, { __index = function(_, k) @@ -13,40 +13,36 @@ setmetatable(lsp_buf_symbols, { end, }) ----@alias lsp_client_t table +---@alias dropbar_lsp_client_t table ----@class lsp_range_t ----@field start {line: integer, character: integer} ----@field end {line: integer, character: integer} - ----@class lsp_location_t +---@class dropbar_lsp_location_t ---@field uri string ----@field range lsp_range_t +---@field range dropbar_range_t ----@class lsp_document_symbol_t +---@class dropbar_lsp_document_symbol_t ---@field name string ---@field kind integer ---@field tags? table ---@field deprecated? boolean ---@field detail? string ----@field range? lsp_range_t ----@field selectionRange? lsp_range_t ----@field children? lsp_document_symbol_t[] +---@field range? dropbar_range_t +---@field selectionRange? dropbar_range_t +---@field children? dropbar_lsp_document_symbol_t[] ----@class lsp_symbol_information_t +---@class dropbar_lsp_symbol_information_t ---@field name string ---@field kind integer ---@field tags? table ---@field deprecated? boolean ----@field location? lsp_location_t +---@field location? dropbar_lsp_location_t ---@field containerName? string ----@class lsp_symbol_information_tree_t: lsp_symbol_information_t ----@field parent? lsp_symbol_information_tree_t ----@field children? lsp_symbol_information_tree_t[] ----@field siblings? lsp_symbol_information_tree_t[] +---@class dropbar_lsp_symbol_information_tree_t: dropbar_lsp_symbol_information_t +---@field parent? dropbar_lsp_symbol_information_tree_t +---@field children? dropbar_lsp_symbol_information_tree_t[] +---@field siblings? dropbar_lsp_symbol_information_tree_t[] ----@alias lsp_symbol_t lsp_document_symbol_t|lsp_symbol_information_t +---@alias dropbar_lsp_symbol_t dropbar_lsp_document_symbol_t|dropbar_lsp_symbol_information_t -- Map symbol number to symbol kind -- stylua: ignore start @@ -87,7 +83,7 @@ local symbol_kind_names = setmetatable({ ---@alias lsp_symbol_type_t 'SymbolInformation'|'DocumentSymbol' ---Return type of the symbol table ----@param symbols lsp_symbol_t[] symbol table +---@param symbols dropbar_lsp_symbol_t[] symbol table ---@return lsp_symbol_type_t? type symbol type local function symbol_type(symbols) if symbols[1] and symbols[1].location then @@ -98,61 +94,11 @@ local function symbol_type(symbols) end end ----Check if cursor is in range ----@param cursor integer[] cursor position (line, character); (1, 0)-based ----@param range lsp_range_t 0-based range ----@return boolean -local function cursor_in_range(cursor, range) - local cursor0 = { cursor[1] - 1, cursor[2] } - -- stylua: ignore start - return ( - cursor0[1] > range.start.line - or (cursor0[1] == range.start.line - and cursor0[2] >= range.start.character) - ) - and ( - cursor0[1] < range['end'].line - or (cursor0[1] == range['end'].line - and cursor0[2] <= range['end'].character) - ) - -- stylua: ignore end -end - ----Check if range1 contains range2 ----Strict indexing -- if range1 == range2, return false ----@param range1 lsp_range_t 0-based range ----@param range2 lsp_range_t 0-based range ----@return boolean -local function range_contains(range1, range2) - -- stylua: ignore start - return ( - range2.start.line > range1.start.line - or (range2.start.line == range1.start.line - and range2.start.character > range1.start.character) - ) - and ( - range2.start.line < range1['end'].line - or (range2.start.line == range1['end'].line - and range2.start.character < range1['end'].character) - ) - and ( - range2['end'].line > range1.start.line - or (range2['end'].line == range1.start.line - and range2['end'].character > range1.start.character) - ) - and ( - range2['end'].line < range1['end'].line - or (range2['end'].line == range1['end'].line - and range2['end'].character < range1['end'].character) - ) - -- stylua: ignore end -end - ---Convert LSP DocumentSymbol into winbar symbol ----@param document_symbol lsp_document_symbol_t LSP DocumentSymbol +---@param document_symbol dropbar_lsp_document_symbol_t LSP DocumentSymbol ---@param buf integer buffer number ---@param win integer window number ----@param siblings lsp_document_symbol_t[]? siblings of the symbol +---@param siblings dropbar_lsp_document_symbol_t[]? siblings of the symbol ---@param idx integer? index of the symbol in siblings ---@return dropbar_symbol_t local function convert_document_symbol( @@ -200,7 +146,7 @@ end ---Convert LSP DocumentSymbol[] into a list of dropbar symbols ---Side effect: change dropbar_symbols ---LSP Specification document: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ ----@param lsp_symbols lsp_document_symbol_t[] +---@param lsp_symbols dropbar_lsp_document_symbol_t[] ---@param dropbar_symbols dropbar_symbol_t[] (reference to) dropbar symbols ---@param buf integer buffer number ---@param win integer window number @@ -219,7 +165,7 @@ local function convert_document_symbol_list( -- Parse in reverse order so that the symbol with the largest start position -- is preferred for idx, symbol in vim.iter(lsp_symbols):enumerate():rev() do - if cursor_in_range(cursor, symbol.range) then + if utils.range.contains_cursor(cursor, symbol.range) then if vim.tbl_contains( configs.opts.sources.lsp.valid_symbols, @@ -246,8 +192,8 @@ local function convert_document_symbol_list( end ---Convert LSP SymbolInformation[] into DocumentSymbol[] ----@param symbols lsp_symbol_t LSP symbols ----@return lsp_document_symbol_t[] +---@param symbols dropbar_lsp_symbol_t LSP symbols +---@return dropbar_lsp_document_symbol_t[] local function unify(symbols) if symbol_type(symbols) == 'DocumentSymbol' or vim.tbl_isempty(symbols) then return symbols @@ -262,9 +208,9 @@ local function unify(symbols) -- symbol can only be a child or a sibling of the previous symbol in the -- same list for list_idx, sym in vim.iter(symbols):enumerate():skip(1) do - local prev = symbols[list_idx - 1] --[[@as lsp_symbol_information_tree_t]] + local prev = symbols[list_idx - 1] --[[@as dropbar_lsp_symbol_information_tree_t]] -- If the symbol is a child of the previous symbol - if range_contains(prev.location.range, sym.location.range) then + if utils.range.contains(prev.location.range, sym.location.range) then sym.parent = prev else -- Else the symbol is a sibling of the previous symbol sym.parent = prev.parent @@ -326,8 +272,8 @@ local function update_symbols(buf, ttl) -- responses can be disordered i.e. later symbols can appear first lsp_buf_symbols[buf] = unify(symbols) - ---@param s1 lsp_document_symbol_t - ---@param s2 lsp_document_symbol_t + ---@param s1 dropbar_lsp_document_symbol_t + ---@param s2 dropbar_lsp_document_symbol_t ---@return boolean precedes true if `s1` appears before `s2` table.sort(lsp_buf_symbols[buf], function(s1, s2) local l1, l2, c1, c2 = diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index a316b64a..704e1151 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -2,8 +2,10 @@ local configs = require('dropbar.configs') local bar = require('dropbar.bar') local utils = require('dropbar.utils') ----@alias dropbar_ts_pos { line: integer, character: integer } ----@alias dropbar_ts_range { start: dropbar_ts_pos, ['end']: dropbar_ts_pos } +-- Max character offset when comparing symbol boundaries during deduplication +-- If two boundaries are on the same line and within this offset, treat them as +-- equal range +local DEDUP_RANGE_MATCH_TOL = 2 ---Convert a snake_case string to camelCase ---@param str string? @@ -58,7 +60,7 @@ local function get_node_short_name(node, buf, cache) end ---@param node TSNode ----@return dropbar_ts_range +---@return dropbar_range_t local function get_node_range(node) local start_line, start_col, end_line, end_col = vim.treesitter.get_node_range(node) @@ -87,7 +89,7 @@ end ---@param node TSNode ---@param buf integer ---@param cache dropbar_ts_cache_t ----@return { name: string, source_range?: dropbar_ts_range }? +---@return { name: string, source_range?: dropbar_range_t }? local function resolve_node_short_name(node, buf, cache) local has_named_children = false local named_children = {} ---@type TSNode[] @@ -164,7 +166,7 @@ end ---@class dropbar_ts_symbol_info ---@field short_type string ---@field kind string ----@field name_info { name: string, source_range?: dropbar_ts_range } +---@field name_info { name: string, source_range?: dropbar_range_t } ---@param node TSNode ---@param buf integer buffer handler @@ -206,8 +208,8 @@ local function valid_node(node, buf, cache) return resolve_symbol_info(node, buf, cache) ~= nil end ----@param a_pos dropbar_ts_pos ----@param b_pos dropbar_ts_pos +---@param a_pos dropbar_pos_t +---@param b_pos dropbar_pos_t ---@return integer local function compare_pos(a_pos, b_pos) if a_pos.line ~= b_pos.line then @@ -219,49 +221,6 @@ local function compare_pos(a_pos, b_pos) return 0 end ----@param range dropbar_ts_range ----@return integer[] -local function to_range4(range) - return { - range.start.line, - range.start.character, - range['end'].line, - range['end'].character, - } -end - ----@param lhs_pos dropbar_ts_pos ----@param rhs_pos dropbar_ts_pos ----@param max_offset integer ----@return boolean -local function pos_matches_with_offset(lhs_pos, rhs_pos, max_offset) - return lhs_pos.line == rhs_pos.line - and math.abs(lhs_pos.character - rhs_pos.character) <= max_offset -end - ----@param outer dropbar_symbol_t ----@param inner dropbar_symbol_t ----@return boolean -local function range_contains(outer, inner) - return vim.treesitter.node_contains(outer.ts_node, to_range4(inner.range)) -end - ----@param lhs_range dropbar_ts_range ----@param rhs_range dropbar_ts_range ----@return boolean -local function range_boundary_matches(lhs_range, rhs_range) - return pos_matches_with_offset(lhs_range.start, rhs_range.start, 2) - or pos_matches_with_offset(lhs_range['end'], rhs_range['end'], 2) -end - ----@param outer_range dropbar_ts_range ----@param inner_range dropbar_ts_range ----@return boolean -local function range_contains_range(outer_range, inner_range) - return compare_pos(outer_range.start, inner_range.start) <= 0 - and compare_pos(outer_range['end'], inner_range['end']) >= 0 -end - ---@param lhs dropbar_symbol_t ---@param rhs dropbar_symbol_t ---@return boolean @@ -275,9 +234,15 @@ local function should_dedupe_adjacent(lhs, rhs) local lhs_contains_rhs, rhs_contains_lhs if lhs.name_source and rhs.name_source then - if range_boundary_matches(lhs.name_source, rhs.name_source) then - lhs_contains_rhs = range_contains(lhs, rhs) - rhs_contains_lhs = range_contains(rhs, lhs) + if + utils.range.matches( + lhs.name_source, + rhs.name_source, + DEDUP_RANGE_MATCH_TOL + ) + then + lhs_contains_rhs = utils.range.contains(lhs.range, rhs.range, false) + rhs_contains_lhs = utils.range.contains(rhs.range, lhs.range, false) return true, lhs_contains_rhs, rhs_contains_lhs end end @@ -288,8 +253,13 @@ local function should_dedupe_adjacent(lhs, rhs) return false, false, false end - lhs_contains_rhs = range_contains(lhs, rhs) - rhs_contains_lhs = range_contains(rhs, lhs) + -- Equal ranges should still deduplicate; strict containment would return false + if same_start and same_end then + return true, false, false + end + + lhs_contains_rhs = utils.range.contains(lhs.range, rhs.range, false) + rhs_contains_lhs = utils.range.contains(rhs.range, lhs.range, false) return lhs_contains_rhs or rhs_contains_lhs, lhs_contains_rhs, rhs_contains_lhs @@ -310,7 +280,11 @@ local function dedupe_adjacent_symbols(symbols) if previous.name_source and current.name_source - and range_contains_range(previous.name_source, current.name_source) + and utils.range.contains( + previous.name_source, + current.name_source, + false + ) then local same_start = compare_pos( previous.name_source.start, diff --git a/lua/dropbar/utils/init.lua b/lua/dropbar/utils/init.lua index f12a2725..1d38d7f3 100644 --- a/lua/dropbar/utils/init.lua +++ b/lua/dropbar/utils/init.lua @@ -2,6 +2,7 @@ return setmetatable({ bar = nil, ---@module 'dropbar.utils.bar' menu = nil, ---@module 'dropbar.utils.menu' source = nil, ---@module 'dropbar.utils.source' + range = nil, ---@module 'dropbar.utils.range' }, { __index = function(_, key) return require('dropbar.utils.' .. key) diff --git a/lua/dropbar/utils/pos.lua b/lua/dropbar/utils/pos.lua new file mode 100644 index 00000000..f459caed --- /dev/null +++ b/lua/dropbar/utils/pos.lua @@ -0,0 +1,18 @@ +local M = {} + +---@class dropbar_pos_t +---@field line integer +---@field character integer + +---Check if two positions are equal within a column tolerance +---Requires both positions to be on the same line; columns may differ by at most `tol`. +---@param pos1 dropbar_pos_t 0-based position +---@param pos2 dropbar_pos_t 0-based position +---@param tol integer maximum allowed column delta (>= 0) +---@return boolean +function M.matches(pos1, pos2, tol) + return pos1.line == pos2.line + and math.abs(pos1.character - pos2.character) <= tol +end + +return M diff --git a/lua/dropbar/utils/range.lua b/lua/dropbar/utils/range.lua new file mode 100644 index 00000000..9d1b65a0 --- /dev/null +++ b/lua/dropbar/utils/range.lua @@ -0,0 +1,83 @@ +local M = {} + +local utils = require('dropbar.utils') + +---@class dropbar_range_t +---@field start dropbar_pos_t +---@field end dropbar_pos_t + +---Check if r1 contains r2 +---Strict indexing -- if r1 == r2, return false +---@param r1 dropbar_range_t 0-based range +---@param r2 dropbar_range_t 0-based range +---@param strict boolean? only return true if `range1` fully contains `range2` (no overlapping boundaries), default false +---@return boolean +function M.contains(r1, r2, strict) + return ( + r2.start.line > r1.start.line + or ( + r2.start.line == r1.start.line + and ( + r2.start.character > r1.start.character + or not strict and r2.start.character == r1.start.character + ) + ) + ) + and (r2.start.line < r1['end'].line or (r2.start.line == r1['end'].line and (r2.start.character < r1['end'].character or not strict and r2.start.character == r1['end'].character))) + and (r2['end'].line > r1.start.line or (r2['end'].line == r1.start.line and (r2['end'].character > r1.start.character or not strict and r2['end'].character == r1.start.character))) + and ( + r2['end'].line < r1['end'].line + or ( + r2['end'].line == r1['end'].line + and ( + r2['end'].character < r1['end'].character + or not strict and r2['end'].character == r1['end'].character + ) + ) + ) +end + +---Check if cursor is in range +---@param cursor integer[] cursor position (line, character); (1, 0)-based +---@param range dropbar_range_t 0-based range +---@param strict boolean? only return true if `cursor` is fully contained in `range` (not on the boundary), default false +---@return boolean +function M.contains_cursor(cursor, range, strict) + cursor = cursor or vim.api.nvim_win_get_cursor(0) + local line = cursor[1] - 1 + local char = cursor[2] + return ( + line > range.start.line + or ( + line == range.start.line + and ( + char > range.start.character + or not strict and char == range.start.character + ) + ) + ) + and ( + line < range['end'].line + or ( + line == range['end'].line + and ( + char < range['end'].character + or not strict and char == range['end'].character + ) + ) + ) +end + +---Check if two ranges match at either boundary within a tolerance +---Two ranges 'match' when their starts are on the same line and within `tol` columns, +---or their ends are on the same line and within `tol` columns. +---@param r1 dropbar_range_t 0-based range +---@param r2 dropbar_range_t 0-based range +---@param tol integer maximum allowed column delta for boundary comparison (>= 0) +---@return boolean +function M.matches(r1, r2, tol) + return utils.pos.matches(r1.start, r2.start, tol) + or utils.pos.matches(r1['end'], r2['end'], tol) +end + +return M From d3538e38ba4f317dc613be04bbea1aef1ae53c5f Mon Sep 17 00:00:00 2001 From: bekaboo Date: Sat, 28 Feb 2026 22:06:18 -0800 Subject: [PATCH 20/21] refactor(treesitter): rename param of `should_dedupe_adjacent` --- lua/dropbar/sources/treesitter.lua | 38 ++++++++++++++---------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 704e1151..2d7032a2 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -221,34 +221,34 @@ local function compare_pos(a_pos, b_pos) return 0 end ----@param lhs dropbar_symbol_t ----@param rhs dropbar_symbol_t +---@param s1 dropbar_symbol_t +---@param s2 dropbar_symbol_t ---@return boolean ----@return boolean lhs_contains_rhs ----@return boolean rhs_contains_lhs -local function should_dedupe_adjacent(lhs, rhs) - if lhs.name ~= rhs.name or lhs.name == '' then +---@return boolean s1_contains_s2 +---@return boolean s2_contains_s1 +local function should_dedupe_adjacent(s1, s2) + if s1.name ~= s2.name or s1.name == '' then return false, false, false end - local lhs_contains_rhs, rhs_contains_lhs + local s1_contains_s2, s2_contains_s1 - if lhs.name_source and rhs.name_source then + if s1.name_source and s2.name_source then if utils.range.matches( - lhs.name_source, - rhs.name_source, + s1.name_source, + s2.name_source, DEDUP_RANGE_MATCH_TOL ) then - lhs_contains_rhs = utils.range.contains(lhs.range, rhs.range, false) - rhs_contains_lhs = utils.range.contains(rhs.range, lhs.range, false) - return true, lhs_contains_rhs, rhs_contains_lhs + s1_contains_s2 = utils.range.contains(s1.range, s2.range, false) + s2_contains_s1 = utils.range.contains(s2.range, s1.range, false) + return true, s1_contains_s2, s2_contains_s1 end end - local same_start = compare_pos(lhs.range.start, rhs.range.start) == 0 - local same_end = compare_pos(lhs.range['end'], rhs.range['end']) == 0 + local same_start = compare_pos(s1.range.start, s2.range.start) == 0 + local same_end = compare_pos(s1.range['end'], s2.range['end']) == 0 if not same_start and not same_end then return false, false, false end @@ -258,11 +258,9 @@ local function should_dedupe_adjacent(lhs, rhs) return true, false, false end - lhs_contains_rhs = utils.range.contains(lhs.range, rhs.range, false) - rhs_contains_lhs = utils.range.contains(rhs.range, lhs.range, false) - return lhs_contains_rhs or rhs_contains_lhs, - lhs_contains_rhs, - rhs_contains_lhs + s1_contains_s2 = utils.range.contains(s1.range, s2.range, false) + s2_contains_s1 = utils.range.contains(s2.range, s1.range, false) + return s1_contains_s2 or s2_contains_s1, s1_contains_s2, s2_contains_s1 end ---@param symbols dropbar_symbol_t[] From b971653e45104508127e1202f249d5a020065d6e Mon Sep 17 00:00:00 2001 From: bekaboo Date: Sat, 28 Feb 2026 22:10:29 -0800 Subject: [PATCH 21/21] refactor(treesitter): rename `dedupe` -> `dedup` --- lua/dropbar/sources/treesitter.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/dropbar/sources/treesitter.lua b/lua/dropbar/sources/treesitter.lua index 2d7032a2..27568f01 100644 --- a/lua/dropbar/sources/treesitter.lua +++ b/lua/dropbar/sources/treesitter.lua @@ -226,7 +226,7 @@ end ---@return boolean ---@return boolean s1_contains_s2 ---@return boolean s2_contains_s1 -local function should_dedupe_adjacent(s1, s2) +local function should_dedup_adjacent(s1, s2) if s1.name ~= s2.name or s1.name == '' then return false, false, false end @@ -265,7 +265,7 @@ end ---@param symbols dropbar_symbol_t[] ---@return dropbar_symbol_t[] -local function dedupe_adjacent_symbols(symbols) +local function dedup_adjacent_symbols(symbols) if #symbols < 2 then return symbols end @@ -311,9 +311,9 @@ local function dedupe_adjacent_symbols(symbols) end end - local should_dedupe, previous_contains_current, current_contains_previous = - should_dedupe_adjacent(previous, current) - if should_dedupe then + local should_dedup, previous_contains_current, current_contains_previous = + should_dedup_adjacent(previous, current) + if should_dedup then if previous_contains_current and not current_contains_previous then -- Keep narrower symbol when names overlap. deduped[#deduped] = current @@ -473,7 +473,7 @@ local function get_symbols(buf, win, cursor) node = node:parent() end - symbols = dedupe_adjacent_symbols(symbols) + symbols = dedup_adjacent_symbols(symbols) utils.bar.set_min_widths(symbols, configs.opts.sources.treesitter.min_widths) return symbols