fix(picker): show regex matches in list when needed. Fixes #878

This commit is contained in:
Folke Lemaitre 2025-02-02 21:14:43 +01:00
parent 2528fcb02c
commit 1d99bac9bc
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
2 changed files with 118 additions and 78 deletions

View file

@ -14,6 +14,7 @@
---@field selected snacks.picker.Item[]
---@field selected_map table<string, snacks.picker.Item>
---@field matcher snacks.picker.Matcher matcher for formatting list items
---@field matcher_regex snacks.picker.Matcher matcher for formatting list items
---@field target? {cursor: number, top?: number}
local M = {}
M.__index = M
@ -63,6 +64,7 @@ function M.new(picker)
self.selected = {}
self.selected_map = {}
self.matcher = require("snacks.picker.core.matcher").new(picker.opts.matcher)
self.matcher_regex = require("snacks.picker.core.matcher").new({ regex = true })
local win_opts = Snacks.win.resolve(picker.opts.win.list, {
show = false,
enter = false,
@ -488,8 +490,10 @@ function M:format(item)
end
-- Highlight match positions for text
local positions = self.matcher:positions({ text = text:gsub("%s*$", ""), idx = 1, score = 0, file = item.file })
for _, pos in ipairs(positions.text or {}) do
local it = { text = text:gsub("%s*$", ""), idx = 1, score = 0, file = item.file }
local positions = self.matcher:positions(it).text or {}
vim.list_extend(positions, self.matcher_regex:positions(it).text or {})
for _, pos in ipairs(positions) do
table.insert(extmarks, {
col = pos - 1,
end_col = pos,
@ -562,10 +566,14 @@ function M:render()
vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, lines)
-- matcher for highlighting should include the search filter
local pattern = vim.trim(self.picker.input.filter.pattern .. " " .. self.picker.input.filter.search)
local pattern = vim.trim(self.picker.input.filter.pattern)
if self.matcher.pattern ~= pattern then
self.matcher:init(pattern)
end
local search = vim.trim(self.picker.input.filter.search)
if self.matcher_regex.pattern ~= search then
self.matcher_regex:init(search)
end
-- render items
for i = self.top, math.min(self:count(), self.top + height - 1) do

View file

@ -4,6 +4,9 @@ local Async = require("snacks.picker.util.async")
---@field match_tick? number
---@field match_topk? number
---@class snacks.picker.matcher.Config
---@field regex? boolean used internally for positions of sources that use regex
---@class snacks.picker.Matcher
---@field opts snacks.picker.matcher.Config
---@field mods snacks.picker.matcher.Mods[][]
@ -34,12 +37,13 @@ local YIELD_MATCH = 1 -- ms
---@field field? string
---@field ignorecase? boolean
---@field fuzzy? boolean
---@field regex? boolean
---@field word? boolean
---@field exact_suffix? boolean
---@field exact_prefix? boolean
---@field inverse? boolean
---@param opts? snacks.picker.matcher.Config
---@param opts? snacks.picker.matcher.Config|{}
function M.new(opts)
local self = setmetatable({}, M)
self.opts = vim.tbl_deep_extend("force", {
@ -172,20 +176,24 @@ function M:init(pattern)
if pattern == "" then
return true
end
local is_or = false
for _, p in ipairs(vim.split(pattern, " +")) do
if p == "|" then
is_or = true
else
local mods = self:_prepare(p)
if mods.pattern ~= "" then
if is_or and #self.mods > 0 then
table.insert(self.mods[#self.mods], mods)
else
table.insert(self.mods, { mods })
if self.opts.regex then
self.mods = { { self:_prepare(pattern) } }
else
local is_or = false
for _, p in ipairs(vim.split(pattern, " +")) do
if p == "|" then
is_or = true
else
local mods = self:_prepare(p)
if mods.pattern ~= "" then
if is_or and #self.mods > 0 then
table.insert(self.mods[#self.mods], mods)
else
table.insert(self.mods, { mods })
end
end
is_or = false
end
is_or = false
end
end
for _, ors in ipairs(self.mods) do
@ -210,74 +218,79 @@ function M:_prepare(pattern)
---@type snacks.picker.matcher.Mods
local mods = { pattern = pattern, entropy = 0, chars = {} }
local file_patterns = {
"^(.*[/\\].*):(%d*):(%d*)$",
"^(.*[/\\].*):(%d*)$",
"^(.+%.[a-z_]+):(%d*):(%d*)$",
"^(.+%.[a-z_]+):(%d*)$",
}
if self.opts.regex then
mods.regex = true
else
local file_patterns = {
"^(.*[/\\].*):(%d*):(%d*)$",
"^(.*[/\\].*):(%d*)$",
"^(.+%.[a-z_]+):(%d*):(%d*)$",
"^(.+%.[a-z_]+):(%d*)$",
}
for _, p in ipairs(file_patterns) do
local file, line, col = pattern:match(p)
if file then
mods.field = "file"
mods.pattern = file .. "$"
self.file = {
path = file,
pos = { tonumber(line) or 1, tonumber(col) or 0 },
}
break
for _, p in ipairs(file_patterns) do
local file, line, col = pattern:match(p)
if file then
mods.field = "file"
mods.pattern = file .. "$"
self.file = {
path = file,
pos = { tonumber(line) or 1, tonumber(col) or 0 },
}
break
end
end
end
-- minimum two chars for field pattern
local field, p = pattern:match("^([%w_][%w_]+):(.*)$")
if field then
mods.field = field
mods.pattern = p
end
mods.ignorecase = self.opts.ignorecase
local is_lower = mods.pattern:lower() == mods.pattern
if self.opts.smartcase then
mods.ignorecase = is_lower
end
mods.fuzzy = self.opts.fuzzy
if not mods.fuzzy then
mods.entropy = mods.entropy + 10
end
if mods.pattern:sub(1, 1) == "!" then
mods.fuzzy, mods.inverse = false, true
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy - 1
end
if mods.pattern:sub(1, 1) == "'" then
mods.fuzzy = false
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy + 10
if mods.pattern:sub(-1, -1) == "'" then
mods.word = true
mods.pattern = mods.pattern:sub(1, -2)
-- minimum two chars for field pattern
local field, p = pattern:match("^([%w_][%w_]+):(.*)$")
if field then
mods.field = field
mods.pattern = p
end
mods.ignorecase = self.opts.ignorecase
local is_lower = mods.pattern:lower() == mods.pattern
if self.opts.smartcase then
mods.ignorecase = is_lower
end
mods.fuzzy = self.opts.fuzzy
if not mods.fuzzy then
mods.entropy = mods.entropy + 10
end
elseif mods.pattern:sub(1, 1) == "^" then
mods.fuzzy, mods.exact_prefix = false, true
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy + 20
end
if mods.pattern:sub(-1, -1) == "$" then
mods.fuzzy = false
mods.exact_suffix = true
mods.pattern = mods.pattern:sub(1, -2)
mods.entropy = mods.entropy + 20
end
local rare_chars = #mods.pattern:gsub("[%w%s]", "")
mods.entropy = mods.entropy + math.min(#mods.pattern, 20) + rare_chars * 2
if not mods.ignorecase and not is_lower then
mods.entropy = mods.entropy * 2
end
if mods.ignorecase then
mods.pattern = mods.pattern:lower()
if mods.pattern:sub(1, 1) == "!" then
mods.fuzzy, mods.inverse = false, true
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy - 1
end
if mods.pattern:sub(1, 1) == "'" then
mods.fuzzy = false
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy + 10
if mods.pattern:sub(-1, -1) == "'" then
mods.word = true
mods.pattern = mods.pattern:sub(1, -2)
mods.entropy = mods.entropy + 10
end
elseif mods.pattern:sub(1, 1) == "^" then
mods.fuzzy, mods.exact_prefix = false, true
mods.pattern = mods.pattern:sub(2)
mods.entropy = mods.entropy + 20
end
if mods.pattern:sub(-1, -1) == "$" then
mods.fuzzy = false
mods.exact_suffix = true
mods.pattern = mods.pattern:sub(1, -2)
mods.entropy = mods.entropy + 20
end
local rare_chars = #mods.pattern:gsub("[%w%s]", "")
mods.entropy = mods.entropy + math.min(#mods.pattern, 20) + rare_chars * 2
if not mods.ignorecase and not is_lower then
mods.entropy = mods.entropy * 2
end
if mods.ignorecase then
mods.pattern = mods.pattern:lower()
end
end
for c = 1, #mods.pattern do
mods.chars[c] = mods.pattern:sub(c, c)
end
@ -413,12 +426,31 @@ function M:fuzzy_positions(str, pattern, from)
return ret
end
---@param str string
---@param pattern string
---@return number? score, number? from, number? to, string? str
function M:regex(str, pattern)
local ok, re = pcall(vim.regex, pattern)
if not ok then
return
end
local from, to = re:match_str(str)
if from and to then
return self.score:get(str, from, to), from, to, str
end
end
---@param item snacks.picker.Item
---@param mods snacks.picker.matcher.Mods
---@return number? score, number? from, number? to, string? str
function M:_match(item, mods)
self.score.is_file = item.file ~= nil
local str = item.text
if mods.regex then
return self:regex(str, mods.pattern)
end
if mods.field then
if item[mods.field] == nil then
if mods.inverse then