Skip to content

Commit

Permalink
feat(completion): improve highlighting in info and signature windows
Browse files Browse the repository at this point in the history
Details:
- Try using tree-sitter highlighting in both windows.
- Conceal characters in info window (might result in extra whitespace).

Resolve #1020
Resolve #1426
  • Loading branch information
echasnovski committed Feb 20, 2025
1 parent a84b7e5 commit 65445ad
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 47 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
## mini.completion

- FEATURE: respect `isIncomplete` in LSP completion response and immediately force new completion request on the next key press.
- FEATURE: both info and signature help windows now use tree-sitter highlighting:
- Info window uses "markdown" parser (works best on Neovim>=0.10 as its parser is built-in). Special markdown characters are concealed (i.e. hidden) which might result into seemingly unnecessary whitespace as dimensions are computed not accounting for that.
- Signature help uses same parser as in current filetype.

## mini.doc

Expand Down
96 changes: 50 additions & 46 deletions lua/mini/completion.lua
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,7 @@ H.show_info_window = function()
lines = H.process_lsp_response(H.info.lsp.result, function(response)
if not response.documentation then return {} end
local res = vim.lsp.util.convert_input_to_markdown_lines(response.documentation)
return H.normalize_lines(res)
return H.normalize_markdown_lines(res)
end)

H.info.lsp.status = 'done'
Expand All @@ -982,25 +982,25 @@ H.show_info_window = function()
-- Don't show anything if there is nothing to show
if not lines or H.is_whitespace(lines) then return end

-- If not already, create a permanent buffer where info will be
-- displayed. For some reason, it is important to have it created not in
-- `setup()` because in that case there is a small flash (which is really a
-- brief open of window at screen top, focus on it, and its close) on the
-- first show of info window.
-- Ensure permanent buffer with "markdown" highlighting to display info
H.ensure_buffer(H.info, 'MiniCompletion:completion-item-info')

-- Add `lines` to info buffer. Use `wrap_at` to have proper width of
-- 'non-UTF8' section separators.
H.stylize_markdown(H.info.bufnr, lines, { wrap_at = H.get_config().window.info.width })
H.ensure_highlight(H.info, 'markdown')
vim.api.nvim_buf_set_lines(H.info.bufnr, 0, -1, false, lines)

-- Compute floating window options
local opts = H.info_window_options()

-- Adjust section separator with better visual alternative
lines = vim.tbl_map(function(l) return l:gsub('^%-%-%-%-*$', string.rep('', opts.width)) end, lines)
vim.api.nvim_buf_set_lines(H.info.bufnr, 0, -1, false, lines)

-- Defer execution because of textlock during `CompleteChanged` event
vim.schedule(function()
-- Ensure that window doesn't open when it shouldn't be
if not (H.pumvisible() and vim.fn.mode() == 'i') then return end
H.open_action_window(H.info, opts)
-- Hide helper syntax elements (like ``` code blocks, etc.)
vim.wo[H.info.win_id].conceallevel = 3
end)
end

Expand All @@ -1009,13 +1009,7 @@ H.info_window_lines = function(info_id)
local completed_item = H.table_get(H.info, { 'event', 'completed_item' }) or {}
local text = completed_item.info or ''

if not H.is_whitespace(text) then
-- Use `<text></text>` to be properly processed by `stylize_markdown()`
local lines = { '<text>' }
vim.list_extend(lines, vim.split(text, '\n'))
table.insert(lines, '</text>')
return lines
end
if not H.is_whitespace(text) then return vim.split(text, '\n') end

-- If popup is not from LSP then there is nothing more to do
if H.completion.source ~= 'lsp' then return nil end
Expand All @@ -1028,7 +1022,7 @@ H.info_window_lines = function(info_id)
local doc = lsp_completion_item.documentation
if doc then
local lines = vim.lsp.util.convert_input_to_markdown_lines(doc)
return H.normalize_lines(lines)
return H.normalize_markdown_lines(lines)
end

-- Finally, try request to resolve current completion to add documentation
Expand Down Expand Up @@ -1136,19 +1130,13 @@ H.show_signature_window = function()
return
end

-- Make markdown code block
table.insert(lines, 1, '```' .. vim.bo.filetype)
table.insert(lines, '```')

-- If not already, create a permanent buffer for signature
-- Ensure permanent buffer with current highlighting to display signature
H.ensure_buffer(H.signature, 'MiniCompletion:signature-help')

-- Add `lines` to signature buffer. Use `wrap_at` to have proper width of
-- 'non-UTF8' section separators.
local buf_id = H.signature.bufnr
H.stylize_markdown(buf_id, lines, { wrap_at = H.get_config().window.signature.width })
H.ensure_highlight(H.signature, vim.bo.filetype)
vim.api.nvim_buf_set_lines(H.signature.bufnr, 0, -1, false, lines)

-- Add highlighting of active parameter
local buf_id = H.signature.bufnr
for i, hl_range in ipairs(hl_ranges) do
if not vim.tbl_isempty(hl_range) and hl_range.first and hl_range.last then
local first, last = hl_range.first, hl_range.last
Expand Down Expand Up @@ -1285,21 +1273,35 @@ end

-- Helpers for floating windows -----------------------------------------------
H.ensure_buffer = function(cache, name)
if type(cache.bufnr) == 'number' and vim.api.nvim_buf_is_valid(cache.bufnr) then return end
if H.is_valid_buf(cache.bufnr) then return end

cache.bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(cache.bufnr, name)
-- Make this buffer a scratch (can close without saving)
vim.fn.setbufvar(cache.bufnr, '&buftype', 'nofile')
local buf_id = vim.api.nvim_create_buf(false, true)
cache.bufnr = buf_id
vim.api.nvim_buf_set_name(buf_id, name)
vim.bo[buf_id].buftype = 'nofile'
end

H.ensure_highlight = function(cache, filetype)
if cache.hl_filetype == filetype then return end
cache.hl_filetype = filetype
local buf_id = cache.bufnr

local has_lang, lang = pcall(vim.treesitter.language.get_lang, filetype)
lang = has_lang and lang or filetype
-- TODO: Remove `opts.error` after compatibility with Neovim=0.11 is dropped
local has_parser, parser = pcall(vim.treesitter.get_parser, buf_id, lang, { error = false })
has_parser = has_parser and parser ~= nil
if has_parser then has_parser = pcall(vim.treesitter.start, buf_id, lang) end
if not has_parser then vim.bo[buf_id].syntax = filetype end
end

-- Returns tuple of height and width
H.floating_dimensions = function(lines, max_height, max_width)
max_height, max_width = math.max(max_height, 1), math.max(max_width, 1)

-- Simulate how lines will look in window with `wrap` and `linebreak`.
-- This is not 100% accurate (mostly when multibyte characters are present
-- manifesting into empty space at bottom), but does the job
-- This is not 100% accurate (mostly because of concealed characters and
-- multibyte manifest into empty space at bottom), but does the job
local lines_wrap = {}
for _, l in pairs(lines) do
vim.list_extend(lines_wrap, H.wrap_line(l, max_width))
Expand Down Expand Up @@ -1336,13 +1338,11 @@ end
H.close_action_window = function(cache, keep_timer)
if not keep_timer then cache.timer:stop() end

if type(cache.win_id) == 'number' and vim.api.nvim_win_is_valid(cache.win_id) then
vim.api.nvim_win_close(cache.win_id, true)
end
if H.is_valid_win(cache.win_id) then vim.api.nvim_win_close(cache.win_id, true) end
cache.win_id = nil

-- For some reason 'buftype' might be reset. Ensure that buffer is scratch.
if cache.bufnr then vim.fn.setbufvar(cache.bufnr, '&buftype', 'nofile') end
if H.is_valid_buf(cache.bufnr) then vim.bo[cache.bufnr].buftype = 'nofile' end
end

-- Utilities ------------------------------------------------------------------
Expand All @@ -1353,6 +1353,10 @@ H.check_type = function(name, val, ref, allow_nil)
H.error(string.format('`%s` should be %s, not %s', name, ref, type(val)))
end

H.is_valid_buf = function(buf_id) return type(buf_id) == 'number' and vim.api.nvim_buf_is_valid(buf_id) end

H.is_valid_win = function(win_id) return type(win_id) == 'number' and vim.api.nvim_win_is_valid(win_id) end

H.is_char_keyword = function(char)
-- Using Vim's `match()` and `keyword` enables respecting Cyrillic letters
return vim.fn.match(char, '[[:keyword:]]') >= 0
Expand Down Expand Up @@ -1452,16 +1456,16 @@ H.map = function(mode, lhs, rhs, opts)
vim.keymap.set(mode, lhs, rhs, opts)
end

H.normalize_lines = function(lines)
-- Enaure no newline characters and no leading/trailing empty lines
lines = table.concat(lines, '\n'):gsub('^\n+', ''):gsub('\n+$', '')
H.normalize_markdown_lines = function(lines)
-- Remove trailing whitespace (converts blank lines to empty)
lines = table.concat(lines, '\n'):gsub('[ \t]+\n', '\n'):gsub('[ \t]+$', '\n')
-- Collapse multiple empty lines, remove top and bottom padding
lines = lines:gsub('\n\n+', '\n\n'):gsub('^\n+', ''):gsub('\n+$', '')
-- Remove padding around code blocks as they are concealed and appear empty
lines = lines:gsub('\n*(\n```%S+\n)', '%1'):gsub('(\n```\n?)\n*', '%1')
return vim.split(lines, '\n')
end

H.stylize_markdown = function(buf_id, lines, opts)
return vim.lsp.util.stylize_markdown(buf_id, H.normalize_lines(lines), opts)
end

-- TODO: Remove after compatibility with Neovim=0.9 is dropped
H.islist = vim.fn.has('nvim-0.10') == 1 and vim.islist or vim.tbl_islist

Expand Down
33 changes: 32 additions & 1 deletion tests/dir-completion/mock-months-lsp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,43 @@ Months.items = {
{ name = 'December', kind = 1 },
}

local markdown_info = {
-- Should remove all blank lines from the top
'',
' ',
'# Month #07',
-- Should collapse multiple blank lines into one
'',
' ',
'\t',
-- Should replace section separator with continuous one spanning window width
'---',
'',
-- Should conceal special characters and highlight
'This *is* __markdown__ text',
'',
' ',
-- Should conceal code block characters *and* remove all blank lines before
-- and after code block (as those will be displayed as empty themselves)
'```lua',
'local a = 1',
'```',
-- Should remove all blank lines from the bottom
' ',
'',
'\t',
' ',
'',
}

Months.data = {
January = { documentation = 'Month #01' },
February = { documentation = 'Month #02' },
March = { documentation = 'Month #03' },
April = { documentation = 'Month #04' },
May = { documentation = 'Month #05' },
June = { documentation = 'Month #06' },
July = { documentation = 'Month #07' },
July = { documentation = table.concat(markdown_info, '\n') },
August = { documentation = 'Month #08' },
September = { documentation = 'Month #09' },
October = { documentation = 'Month #10' },
Expand Down Expand Up @@ -141,6 +170,8 @@ Months.requests = {
local label, parameters
if word == 'long(' then
label = string.rep('a ', 1000)
elseif word == 'string.format(' then
label = 'function string.format(s:string|number, ...any)'
else
label = 'abc(param1, param2)'
parameters = { { label = { 4, 10 } }, { label = { 12, 18 } } }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
--|---------|---------|---------|---------|-----
01|July
02|July Function # Month #07
03|~
04|~ ───────────────────────────
05|~
06|~ This is markdown text
07|~
08|~ local a = 1
09|~
10|~
11|~
12|~
13|~
14|[No Name] [+] 1,5 All
15|-- INSERT --

--|---------|---------|---------|---------|-----
01|000000000000000000000000000000000000000000000
02|111111111111111222222222223333333333333333444
03|444444444444444333333333333333333333333333444
04|444444444444444333333333333333333333333333444
05|444444444444444333333333333333333333333333444
06|444444444444444333335536666666633333333333444
07|444444444444444333333333333333333333333333444
08|444444444444444666667373783333333333333333444
09|444444444444444333333333333333333333333333444
10|444444444444444444444444444444444444444444444
11|444444444444444444444444444444444444444444444
12|444444444444444444444444444444444444444444444
13|444444444444444444444444444444444444444444444
14|999999999999999999999999999999999999999999999
15|::::::::::::;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
--|---------|---------|---------|---------|---------|---------|-----
01|string.format(
02|~ function string.format(s:string|number, ...any)
03|~
04|~
05|~
06|~
07|~
08|~
09|[No Name] [+] 1,15 All
10|-- INSERT --

--|---------|---------|---------|---------|---------|---------|-----
01|00000012222221333333333333333333333333333333333333333333333333333
02|44444444444444555555555666666788888875766666659999997566655574444
03|44444444444444444444444444444444444444444444444444444444444444444
04|44444444444444444444444444444444444444444444444444444444444444444
05|44444444444444444444444444444444444444444444444444444444444444444
06|44444444444444444444444444444444444444444444444444444444444444444
07|44444444444444444444444444444444444444444444444444444444444444444
08|44444444444444444444444444444444444444444444444444444444444444444
09|:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
10|;;;;;;;;;;;;<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
21 changes: 21 additions & 0 deletions tests/test_completion.lua
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,17 @@ T['Information window']['adjusts window width'] = function()
child.expect_screenshot()
end

T['Information window']['stylizes markdown with concealed characters'] = function()
if child.fn.has('nvim-0.10') == 0 then MiniTest.skip('Screenshots are generated for Neovim>=0.10') end

child.set_size(15, 45)
type_keys('i', 'Jul', '<C-Space>')
type_keys('<C-n>')
eq(get_floating_windows(), {})
sleep(default_info_delay + small_time)
child.expect_screenshot()
end

T['Information window']['implements debounce-style delay'] = function()
type_keys('i', 'J', '<C-Space>')
eq(get_completion(), { 'January', 'June', 'July' })
Expand Down Expand Up @@ -1039,6 +1050,16 @@ T['Signature help']['adjusts window height'] = function()
child.expect_screenshot()
end

T['Signature help']['stylizes markdown with concealed characters'] = function()
if child.fn.has('nvim-0.10') == 0 then MiniTest.skip('Screenshots are generated for Neovim>=0.10') end

child.set_size(10, 65)
child.bo.filetype = 'lua'
type_keys('i', 'string.format(')
sleep(default_signature_delay + small_time)
child.expect_screenshot()
end

T['Signature help']['implements debounce-style delay'] = function()
child.cmd('startinsert')
type_keys('abc(')
Expand Down

0 comments on commit 65445ad

Please sign in to comment.