├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .luarc.jsonc ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── doc └── hlslens.txt ├── lua ├── hlslens.lua └── hlslens │ ├── cmdline │ ├── fold.lua │ ├── init.lua │ └── parser.lua │ ├── config.lua │ ├── decorator.lua │ ├── ext │ └── ufo.lua │ ├── highlight.lua │ ├── lib │ ├── debounce.lua │ ├── disposable.lua │ ├── event.lua │ └── throttle.lua │ ├── main.lua │ ├── position │ ├── init.lua │ └── range │ │ ├── qf.lua │ │ └── regex.lua │ ├── qf.lua │ ├── render │ ├── extmark.lua │ ├── floatwin.lua │ ├── init.lua │ └── winhl.lua │ ├── utils.lua │ └── wffi.lua └── plugin └── hlslens.vim /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | tab_width = 4 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.lua] 10 | align_continuous_assign_statement = false 11 | space_around_table_field_list = false 12 | align_call_args = false 13 | quote_style = single 14 | 15 | [*.json,*.jsonc] 16 | indent_style = tab 17 | 18 | [{Makefile,**.mk}] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | - `nvim --version`: 12 | - Operating system/version: 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce using `nvim -u mini.lua`** 18 | 19 | Example: 20 | `cat mini.lua` 21 | 22 | ```lua 23 | -- packer 24 | use {'kevinhwang91/nvim-hlslens'} 25 | 26 | require('hlslens').setup() 27 | 28 | local kopts = {noremap = true, silent = true} 29 | 30 | vim.api.nvim_set_keymap('n', 'n', 31 | [[execute('normal! ' . v:count1 . 'n')lua require('hlslens').start()]], 32 | kopts) 33 | vim.api.nvim_set_keymap('n', 'N', 34 | [[execute('normal! ' . v:count1 . 'N')lua require('hlslens').start()]], 35 | kopts) 36 | vim.api.nvim_set_keymap('n', '*', [[*lua require('hlslens').start()]], kopts) 37 | vim.api.nvim_set_keymap('n', '#', [[#lua require('hlslens').start()]], kopts) 38 | vim.api.nvim_set_keymap('n', 'g*', [[g*lua require('hlslens').start()]], kopts) 39 | vim.api.nvim_set_keymap('n', 'g#', [[g#lua require('hlslens').start()]], kopts) 40 | 41 | vim.api.nvim_set_keymap('x', '*', [[*lua require('hlslens').start()]], kopts) 42 | vim.api.nvim_set_keymap('x', '#', [[#lua require('hlslens').start()]], kopts) 43 | vim.api.nvim_set_keymap('x', 'g*', [[g*lua require('hlslens').start()]], kopts) 44 | vim.api.nvim_set_keymap('x', 'g#', [[g#lua require('hlslens').start()]], kopts) 45 | 46 | vim.api.nvim_set_keymap('n', 'l', ':noh', kopts) 47 | ``` 48 | 49 | Steps to reproduce the behavior: 50 | 51 | 1. 52 | 2. 53 | 3. 54 | 55 | **Expected behavior** 56 | A clear and concise description of what you expected to happen. 57 | 58 | **Screenshots** 59 | If applicable, add screenshots to help explain your problem. 60 | 61 | **Additional context** 62 | Add any other context about the problem here. 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc/tags 2 | build 3 | -------------------------------------------------------------------------------- /.luarc.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", 3 | "completion.callSnippet": "Replace", 4 | "completion.displayContext": 50, 5 | "completion.keywordSnippet": "Disable", 6 | "completion.postfix": ".", 7 | "diagnostics.libraryFiles": "Disable", 8 | "diagnostics.disable": [ 9 | "different-requires", 10 | "param-type-mismatch", 11 | "assign-type-mismatch" 12 | ], 13 | "diagnostics.globals": [ 14 | "jit", 15 | "it", 16 | "describe", 17 | "before_each", 18 | "after_each", 19 | "setup", 20 | "teardown" 21 | ], 22 | "diagnostics.severity": { 23 | "unused-local": "Hint", 24 | "undefined-field": "Hint" 25 | }, 26 | "runtime.version": "LuaJIT", 27 | "type.castNumberToInteger": true, 28 | "type.weakUnionCheck": true, 29 | "workspace.library": [ 30 | "$VIM/runtime/lua" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.0] - 2024-12-31 4 | 5 | ### Features 6 | 7 | #### Highlight 8 | 9 | - Link HlSearchNear to CurSearch instead (#54) 10 | 11 | ### Bug Fixes 12 | 13 | #### WFFI 14 | 15 | - Use `ml_get_buf_len` directly (#70) 16 | - Pass bufnr to build regex 17 | 18 | #### Cmdline 19 | 20 | - Limit search range for substitute (#60) 21 | - Valid range for substitute (#60) 22 | - Clear range to keep position valid 23 | 24 | #### Render 25 | 26 | - Override `Normal` in floatwin 27 | - Nightly change `nvim_win_get_config` return val 28 | - Check `v:hlsearch` before refreshing (#63) 29 | - More compact padding highlight group 30 | 31 | #### Miscellaneous 32 | 33 | - Use `unpack` in utils to avoid serialization issue 34 | - Remove redundant hl group HlSearchFloat (#61) 35 | - Fix Ufo ext issue 36 | - [**breaking**] Remove warning about setup (#73) 37 | 38 | ## [1.0.0] - 2022-12-10 39 | 40 | ### Features 41 | 42 | #### Cmdline 43 | 44 | - Support incsearch for `smagic` and `snomagic` 45 | - Highlight selection for `\%V` 46 | 47 | #### API 48 | 49 | - Add qf API `exportLastSearchToQuickfix` [#49] 50 | 51 | #### External 52 | 53 | - Support nvim-ufo [#43] 54 | 55 | ### Bug Fixes 56 | 57 | #### WFFI 58 | 59 | - Add `rmm_matchcol` field to `regmmatch_T` in nightly 60 | 61 | #### Miscellaneous 62 | 63 | - Listen `v:hlsearch` value, `:noh` remapping is not necessary 64 | - [**breaking**] Bump Neovim to 0.6.1 65 | 66 | ### Performance 67 | 68 | - Use throttle and debounce to improve performance 69 | 70 | ## [0.2.0] - 2022-09-11 71 | 72 | Release the stable version, will rework some tasks. 73 | 74 | ### Features 75 | 76 | - Improve `calm_down` option 77 | - Respect foldopen option while searching 78 | - Use FFI to build position index 79 | - Support `;` offset 80 | - Support ffi for Windows 81 | 82 | ### Performance 83 | 84 | - Improve performance while moving cursor 85 | - Use `searchcount` to get better performance while searching 86 | 87 | ## [0.1.0] 88 | 89 | - rename `HlSearchCur` to `HlSearchNear`. 90 | - rename `HlSearchLensCur` to `HlSearchLensNear`. 91 | - replace `override_line_lens` with `override_lens`. 92 | - add `HlSearchFloat` highlight the nearest text for the floating window. 93 | - add `nearest_only` option to add lens for the nearest instance and ignore others. 94 | - add `nearest_float_when` and `float_shadow_blend` options for displaying nearest instance lens 95 | - with floating window. 96 | - add `virt_priority` option to specify the priority of `nvim_buf_set_extmark`. 97 | - add `enable_incsearch` option to display the current matched instance lens while searching. 98 | - support `/s` and `/e` offsets for search, but don't support offset number. 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021-2023, kevinhwang91 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | DEPS ?= build 3 | 4 | LUA_LS ?= $(DEPS)/lua-language-server 5 | LINT_LEVEL ?= Information 6 | 7 | all: 8 | 9 | lint: 10 | @rm -rf $(LUA_LS) 11 | @mkdir -p $(LUA_LS) 12 | @VIM=$(HOME)/.local/share/nvim lua-language-server --check $(PWD) --checklevel=$(LINT_LEVEL) --logpath=$(LUA_LS) 13 | @[[ -f $(LUA_LS)/check.json ]] && { cat $(LUA_LS)/check.json 2>/dev/null; exit 1; } || true 14 | 15 | clean: 16 | rm -rf $(DEPS) 17 | 18 | .PHONY: all clean lint 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-hlslens 2 | 3 | nvim-hlslens helps you better glance at matched information, seamlessly jump between matched 4 | instances. 5 | 6 | 7 | 8 | ## Table of contents 9 | 10 | - [Table of contents](#table-of-contents) 11 | - [Features](#features) 12 | - [Quickstart](#quickstart) 13 | - [Requirements](#requirements) 14 | - [Installation](#installation) 15 | - [Minimal configuration](#minimal-configuration) 16 | - [Usage](#usage) 17 | - [Start hlslens](#start-hlslens) 18 | - [Stop hlslens](#stop-hlslens) 19 | - [Documentation](#documentation) 20 | - [Setup and description](#setup-and-description) 21 | - [Highlight](#highlight) 22 | - [Commands](#commands) 23 | - [API](#api) 24 | - [Advanced configuration](#advanced-configuration) 25 | - [Customize configuration](#customize-configuration) 26 | - [Customize virtual text](#customize-virtual-text) 27 | - [Integrate with other plugins](#integrate-with-other-plugins) 28 | - [vim-asterisk](https://github.com/haya14busa/vim-asterisk) 29 | - [nvim-ufo](https://github.com/kevinhwang91/nvim-ufo) 30 | - [vim-visual-multi](https://github.com/mg979/vim-visual-multi) 31 | - [Feedback](#feedback) 32 | - [License](#license) 33 | 34 | ## Features 35 | 36 | - Fully customizable style of virtual text 37 | - Clear highlighting and virtual text when cursor is out of range 38 | - Display search result dynamically while cursor is moving 39 | - Display search result for the current matched instance while searching 40 | - Display search result for some built-in commands that support incsearch (need Neovim 0.8.0) 41 | 42 | > Need `vim.api.nvim_parse_cmd` to parse built-in commands if incsearch is enabled. 43 | 44 | ## Quickstart 45 | 46 | ### Requirements 47 | 48 | - [Neovim](https://github.com/neovim/neovim) 0.7.2 or later 49 | - [nvim-ufo](https://github.com/kevinhwang91/nvim-ufo) (optional) 50 | 51 | ### Installation 52 | 53 | Install nvim-hlslens with [Packer.nvim](https://github.com/wbthomason/packer.nvim): 54 | 55 | ```lua 56 | use {'kevinhwang91/nvim-hlslens'} 57 | ``` 58 | 59 | ### Minimal configuration 60 | 61 | ```lua 62 | require('hlslens').setup() 63 | 64 | local kopts = {noremap = true, silent = true} 65 | 66 | vim.api.nvim_set_keymap('n', 'n', 67 | [[execute('normal! ' . v:count1 . 'n')lua require('hlslens').start()]], 68 | kopts) 69 | vim.api.nvim_set_keymap('n', 'N', 70 | [[execute('normal! ' . v:count1 . 'N')lua require('hlslens').start()]], 71 | kopts) 72 | vim.api.nvim_set_keymap('n', '*', [[*lua require('hlslens').start()]], kopts) 73 | vim.api.nvim_set_keymap('n', '#', [[#lua require('hlslens').start()]], kopts) 74 | vim.api.nvim_set_keymap('n', 'g*', [[g*lua require('hlslens').start()]], kopts) 75 | vim.api.nvim_set_keymap('n', 'g#', [[g#lua require('hlslens').start()]], kopts) 76 | 77 | vim.api.nvim_set_keymap('n', 'l', 'noh', kopts) 78 | ``` 79 | 80 | ### Usage 81 | 82 | After using [Minimal configuration](#minimal-configuration): 83 | 84 | Hlslens will add virtual text at the end of the line if the room is enough for virtual text, 85 | otherwise, add a floating window to overlay the statusline to display lens. 86 | 87 | You can glance at the result provided by lens while searching when `incsearch` is on. Hlslens also 88 | supports `` and `` to move to the next and previous match. 89 | 90 | #### Start hlslens 91 | 92 | 1. Press `/` or `?` to search text, `/s` and `/e` offsets are supported; 93 | 2. Invoke API `require('hlslens').start()`; 94 | 95 | #### Stop hlslens 96 | 97 | 1. Run ex command `nohlsearch`; 98 | 2. Map key to `:nohlsearch`; 99 | 3. Invoke API `require('hlslens').stop()`; 100 | 101 | ## Documentation 102 | 103 | ### Setup and description 104 | 105 | ```lua 106 | { 107 | auto_enable = { 108 | description = [[Enable nvim-hlslens automatically]], 109 | default = true 110 | }, 111 | enable_incsearch = { 112 | description = [[When `incsearch` option is on and enable_incsearch is true, add lens 113 | for the current matched instance]], 114 | default = true 115 | }, 116 | calm_down = { 117 | description = [[If calm_down is true, clear all lens and highlighting When the cursor is 118 | out of the position range of the matched instance or any texts are changed]], 119 | default = false, 120 | }, 121 | nearest_only = { 122 | description = [[Only add lens for the nearest matched instance and ignore others]], 123 | default = false 124 | }, 125 | nearest_float_when = { 126 | description = [[When to open the floating window for the nearest lens. 127 | 'auto': floating window will be opened if room isn't enough for virtual text; 128 | 'always': always use floating window instead of virtual text; 129 | 'never': never use floating window for the nearest lens]], 130 | default = 'auto', 131 | }, 132 | float_shadow_blend = { 133 | description = [[Winblend of the nearest floating window. `:h winbl` for more details]], 134 | default = 50, 135 | }, 136 | virt_priority = { 137 | description = [[Priority of virtual text, set it lower to overlay others. 138 | `:h nvim_buf_set_extmark` for more details]], 139 | default = 100, 140 | }, 141 | override_lens = { 142 | description = [[Hackable function for customizing the lens. If you like hacking, you 143 | should search `override_lens` and inspect the corresponding source code. 144 | There's no guarantee that this function will not be changed in the future. If it is 145 | changed, it will be listed in the CHANGES file. 146 | @param render table an inner module for hlslens, use `setVirt` to set virtual text 147 | @param splist table (1,1)-indexed position 148 | @param nearest boolean whether nearest lens 149 | @param idx number nearest index in the plist 150 | @param relIdx number relative index, negative means before current position, 151 | positive means after 152 | ]], 153 | default = nil 154 | }, 155 | } 156 | ``` 157 | 158 | ### Highlight 159 | 160 | ```vim 161 | hi default link HlSearchNear CurSearch 162 | hi default link HlSearchLens WildMenu 163 | hi default link HlSearchLensNear CurSearch 164 | ``` 165 | 166 | 1. HlSearchLensNear: highlight the nearest virtual text for the floating window 167 | 2. HlSearchLens: highlight virtual text except for the nearest one 168 | 3. HlSearchNear: highlight the nearest matched instance 169 | 170 | ### Commands 171 | 172 | - `HlSearchLensToggle`: Toggle nvim-hlslens enable/disable 173 | - `HlSearchLensEnable`: Enable nvim-hlslens 174 | - `HlSearchLensDisable`: Disable nvim-hlslens 175 | 176 | ### API 177 | 178 | [hlslens.lua](./lua/hlslens.lua) 179 | 180 | ## Advanced configuration 181 | 182 | ### Customize configuration 183 | 184 | ```lua 185 | require('hlslens').setup({ 186 | calm_down = true, 187 | nearest_only = true, 188 | nearest_float_when = 'always' 189 | }) 190 | 191 | -- run `:nohlsearch` and export results to quickfix 192 | -- if Neovim is 0.8.0 before, remap yourself. 193 | vim.keymap.set({'n', 'x'}, 'L', function() 194 | vim.schedule(function() 195 | if require('hlslens').exportLastSearchToQuickfix() then 196 | vim.cmd('cw') 197 | end 198 | end) 199 | return ':noh' 200 | end, {expr = true}) 201 | ``` 202 | 203 | 204 | 205 | ### Customize virtual text 206 | 207 | ```lua 208 | require('hlslens').setup({ 209 | override_lens = function(render, posList, nearest, idx, relIdx) 210 | local sfw = vim.v.searchforward == 1 211 | local indicator, text, chunks 212 | local absRelIdx = math.abs(relIdx) 213 | if absRelIdx > 1 then 214 | indicator = ('%d%s'):format(absRelIdx, sfw ~= (relIdx > 1) and '▲' or '▼') 215 | elseif absRelIdx == 1 then 216 | indicator = sfw ~= (relIdx == 1) and '▲' or '▼' 217 | else 218 | indicator = '' 219 | end 220 | 221 | local lnum, col = unpack(posList[idx]) 222 | if nearest then 223 | local cnt = #posList 224 | if indicator ~= '' then 225 | text = ('[%s %d/%d]'):format(indicator, idx, cnt) 226 | else 227 | text = ('[%d/%d]'):format(idx, cnt) 228 | end 229 | chunks = {{' '}, {text, 'HlSearchLensNear'}} 230 | else 231 | text = ('[%s %d]'):format(indicator, idx) 232 | chunks = {{' '}, {text, 'HlSearchLens'}} 233 | end 234 | render.setVirt(0, lnum - 1, col - 1, chunks, nearest) 235 | end 236 | }) 237 | ``` 238 | 239 |

240 | 241 |

242 | 243 | ### Integrate with other plugins 244 | 245 | #### [vim-asterisk](https://github.com/haya14busa/vim-asterisk) 246 | 247 | ```lua 248 | -- packer 249 | use 'haya14busa/vim-asterisk' 250 | 251 | vim.api.nvim_set_keymap('n', '*', [[(asterisk-z*)lua require('hlslens').start()]], {}) 252 | vim.api.nvim_set_keymap('n', '#', [[(asterisk-z#)lua require('hlslens').start()]], {}) 253 | vim.api.nvim_set_keymap('n', 'g*', [[(asterisk-gz*)lua require('hlslens').start()]], {}) 254 | vim.api.nvim_set_keymap('n', 'g#', [[(asterisk-gz#)lua require('hlslens').start()]], {}) 255 | 256 | vim.api.nvim_set_keymap('x', '*', [[(asterisk-z*)lua require('hlslens').start()]], {}) 257 | vim.api.nvim_set_keymap('x', '#', [[(asterisk-z#)lua require('hlslens').start()]], {}) 258 | vim.api.nvim_set_keymap('x', 'g*', [[(asterisk-gz*)lua require('hlslens').start()]], {}) 259 | vim.api.nvim_set_keymap('x', 'g#', [[(asterisk-gz#)lua require('hlslens').start()]], {}) 260 | ``` 261 | 262 | #### [nvim-ufo](https://github.com/kevinhwang91/nvim-ufo) 263 | 264 | The lens has been adapted to the folds of nvim-ufo, still need remap `n` and `N` action if you want 265 | to peek at folded lines. 266 | 267 | ```lua 268 | -- packer 269 | use {'kevinhwang91/nvim-ufo', requires = 'kevinhwang91/promise-async'} 270 | 271 | -- if Neovim is 0.8.0 before, remap yourself. 272 | local function nN(char) 273 | local ok, winid = hlslens.nNPeekWithUFO(char) 274 | if ok and winid then 275 | -- Safe to override buffer scope keymaps remapped by ufo, 276 | -- ufo will restore previous buffer keymaps before closing preview window 277 | -- Type will switch to preview window and fire `trace` action 278 | vim.keymap.set('n', '', function() 279 | return '' 280 | end, {buffer = true, remap = true, expr = true}) 281 | end 282 | end 283 | 284 | vim.keymap.set({'n', 'x'}, 'n', function() nN('n') end) 285 | vim.keymap.set({'n', 'x'}, 'N', function() nN('N') end) 286 | ``` 287 | 288 | #### [vim-visual-multi](https://github.com/mg979/vim-visual-multi) 289 | 290 | 291 | 292 | ```lua 293 | -- packer 294 | use 'mg979/vim-visual-multi' 295 | 296 | local hlslens = require('hlslens') 297 | if hlslens then 298 | local overrideLens = function(render, posList, nearest, idx, relIdx) 299 | local _ = relIdx 300 | local lnum, col = unpack(posList[idx]) 301 | 302 | local text, chunks 303 | if nearest then 304 | text = ('[%d/%d]'):format(idx, #posList) 305 | chunks = {{' ', 'Ignore'}, {text, 'VM_Extend'}} 306 | else 307 | text = ('[%d]'):format(idx) 308 | chunks = {{' ', 'Ignore'}, {text, 'HlSearchLens'}} 309 | end 310 | render.setVirt(0, lnum - 1, col - 1, chunks, nearest) 311 | end 312 | local lensBak 313 | local config = require('hlslens.config') 314 | local gid = vim.api.nvim_create_augroup('VMlens', {}) 315 | vim.api.nvim_create_autocmd('User', { 316 | pattern = {'visual_multi_start', 'visual_multi_exit'}, 317 | group = gid, 318 | callback = function(ev) 319 | if ev.match == 'visual_multi_start' then 320 | lensBak = config.override_lens 321 | config.override_lens = overrideLens 322 | else 323 | config.override_lens = lensBak 324 | end 325 | hlslens.start() 326 | end 327 | }) 328 | end 329 | ``` 330 | 331 | ## Feedback 332 | 333 | - If you get an issue or come up with an awesome idea, don't hesitate to open an issue in github. 334 | - If you think this plugin is useful or cool, consider rewarding it a star. 335 | 336 | ## License 337 | 338 | The project is licensed under a BSD-3-clause license. See [LICENSE](./LICENSE) file for details. 339 | -------------------------------------------------------------------------------- /doc/hlslens.txt: -------------------------------------------------------------------------------- 1 | *hlslens.txt* 2 | *nvimhlslens.txt* 3 | *nvim-hlslens.txt* 4 | 5 | `URL`: https://github.com/kevinhwang91/nvim-hlslens 6 | 7 | `PATH`: ../README.md 8 | 9 | press `gf` or `CTRL-W gf` under the PATH value :) 10 | 11 | vim:ft=help 12 | -------------------------------------------------------------------------------- /lua/hlslens.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local initialized = false 4 | 5 | ---Enable hlslens 6 | ---@return boolean ret return true if successful, otherwise return false 7 | function M.enable() 8 | return require('hlslens.main').enable() 9 | end 10 | 11 | ---Disable hlslens 12 | ---@return boolean ret return true if successful, otherwise return false 13 | function M.disable() 14 | return require('hlslens.main').disable() 15 | end 16 | 17 | ---Check out enabled for hlslens 18 | ---@return boolean ret return true if enabled, otherwise return false 19 | function M.isEnabled() 20 | return require('hlslens.main').isEnabled() 21 | end 22 | 23 | ---Toggle hlslens 24 | ---@return boolean ret return true if enabled, otherwise return false 25 | function M.toggle() 26 | if M.isEnabled() then 27 | M.disable() 28 | vim.notify('Disable nvim-hlslens', vim.log.levels.INFO) 29 | else 30 | M.enable() 31 | vim.notify('Enable nvim-hlslens', vim.log.levels.INFO) 32 | end 33 | return M.isEnabled() 34 | end 35 | 36 | ---Start to render 37 | ---@return boolean ret return true if enabled, otherwise return false 38 | function M.start() 39 | return require('hlslens.main').start() 40 | end 41 | 42 | ---Stop to render 43 | ---@return boolean ret return true if enabled, otherwise return false 44 | function M.stop() 45 | return require('hlslens.main').stop() 46 | end 47 | 48 | ---Export last search results to quickfix 49 | ---@param isLocation? boolean export to location list if true, otherwise export to quickfix list 50 | ---@return boolean ret return true if successful, otherwise return false 51 | function M.exportLastSearchToQuickfix(isLocation) 52 | return require('hlslens.main').exportToQuickfix(isLocation) 53 | end 54 | 55 | ---Wrap 'n' and 'N' actions with nvim-ufo's peekFoldedLinesUnderCursor API, and start to render 56 | ---@param char string|'n'|'N' 57 | ---@param ... any parameters of `peekFoldedLinesUnderCursor` API for nvim-ufo 58 | ---@return boolean ret return true if enabled, otherwise return false 59 | ---@return number winid 60 | function M.nNPeekWithUFO(char, ...) 61 | return require('hlslens.ext.ufo'):nN(char, ...) 62 | end 63 | 64 | function M.setup(opts) 65 | if initialized and (not M._config or not opts) then 66 | return 67 | end 68 | 69 | opts = opts or {} 70 | -- M._config will become nil latter 71 | M._config = opts 72 | if opts.auto_enable ~= false then 73 | M.enable() 74 | end 75 | initialized = true 76 | end 77 | 78 | return M 79 | -------------------------------------------------------------------------------- /lua/hlslens/cmdline/fold.lua: -------------------------------------------------------------------------------- 1 | local fn = vim.fn 2 | local cmd = vim.cmd 3 | 4 | ---@class HlslensCmdLineIncSearchFold 5 | ---@field undoLnums number[] 6 | local IncSearchFold = {} 7 | 8 | function IncSearchFold:new() 9 | local o = setmetatable({}, self) 10 | self.__index = self 11 | o.undoLnums = {} 12 | return o 13 | end 14 | 15 | function IncSearchFold:expand(lnum) 16 | self:undo() 17 | self:openRecursively(lnum) 18 | end 19 | 20 | function IncSearchFold:openRecursively(lnum) 21 | repeat 22 | local l = fn.foldclosed(lnum) 23 | if l > 0 then 24 | cmd(lnum .. 'foldopen') 25 | table.insert(self.undoLnums, l) 26 | end 27 | until l == -1 28 | end 29 | 30 | function IncSearchFold:undo() 31 | while #self.undoLnums > 0 do 32 | local l = table.remove(self.undoLnums) 33 | cmd(l .. 'foldclose') 34 | end 35 | end 36 | 37 | return IncSearchFold 38 | -------------------------------------------------------------------------------- /lua/hlslens/cmdline/init.lua: -------------------------------------------------------------------------------- 1 | local fn = vim.fn 2 | local cmd = vim.cmd 3 | local api = vim.api 4 | local uv = vim.loop 5 | 6 | local utils = require('hlslens.utils') 7 | local render = require('hlslens.render') 8 | local config = require('hlslens.config') 9 | local event = require('hlslens.lib.event') 10 | local parser = require('hlslens.cmdline.parser') 11 | local fold = require('hlslens.cmdline.fold') 12 | local debounce = require('hlslens.lib.debounce') 13 | local disposable = require('hlslens.lib.disposable') 14 | 15 | ---@class HlslensCmdLine 16 | ---@field attached boolean 17 | ---@field incSearch boolean 18 | ---@field initialized boolean 19 | ---@field disposables HlslensDisposable[] 20 | ---@field ns number 21 | ---@field currentIdx number 22 | ---@field total number 23 | ---@field hrtime number 24 | ---@field parser HlslensCmdLineParser 25 | ---@field isSubstitute boolean 26 | ---@field isVisualArea boolean 27 | ---@field range? number[] 28 | ---@field searching boolean 29 | ---@field fold? HlslensCmdLineIncSearchFold 30 | ---@field debouncedSearch HlslensDebounce 31 | ---@field searchStart number[] (1, 0)-indexed position 32 | ---@field matchStart number[] (1, 0)-indexed position 33 | ---@field matchEnd number[] (1, 0)-indexed position 34 | ---@field keyCode number[] 35 | local CmdLine = { 36 | initialized = false, 37 | disposables = {} 38 | } 39 | 40 | local function decPos(pos) 41 | return {pos[1], math.max(0, pos[2] - 1)} 42 | end 43 | 44 | function CmdLine:resetState() 45 | self.currentIdx = 0 46 | self.total = 0 47 | end 48 | 49 | --- 50 | ---@return boolean 51 | function CmdLine:typeUpDown() 52 | -- = 0x80 0x6b 0x75 53 | -- = 0x80 0x6b 0x64 54 | return self.keyCode[1] == 0x80 and self.keyCode[2] == 0x6b and 55 | (self.keyCode[3] == 0x75 or self.keyCode[3] == 0x64) 56 | end 57 | 58 | --- 59 | ---@param pos number[] 60 | ---@param endPos? number[] 61 | function CmdLine:doRender(pos, endPos) 62 | render.clear(true, 0, true) 63 | if endPos then 64 | render.addWinHighlight(0, pos, endPos) 65 | end 66 | render:addNearestLens(0, pos, self.currentIdx, self.total) 67 | if self.fold then 68 | self.fold:expand(pos[1]) 69 | end 70 | end 71 | 72 | ---may_do_incsearch_highlighting 73 | ---@param pattern string 74 | ---@return table|nil 75 | function CmdLine:searchRange(pattern) 76 | api.nvim_win_set_cursor(0, self.searchStart) 77 | local flag = self.isSubstitute and 'c' or (self.type == '?' and 'b' or '') 78 | if self.range then 79 | flag = flag .. 'n' 80 | end 81 | local pos = utils.searchPosSafely(pattern, flag) 82 | if utils.comparePosition(pos, {0, 0}) == 0 then 83 | return 84 | end 85 | if self.range then 86 | if pos[1] < self.range[1] or pos[1] > self.range[2] then 87 | return 88 | end 89 | api.nvim_win_set_cursor(0, decPos(pos)) 90 | end 91 | local ok, res = pcall(fn.searchcount, { 92 | recompute = true, 93 | maxcount = 100000, 94 | timeout = 100, 95 | pattern = pattern 96 | }) 97 | local range 98 | if ok and res.incomplete == 0 and res.total and res.total > 0 then 99 | self.currentIdx = res.current 100 | self.total = res.total 101 | local endPos = utils.searchPosSafely(pattern, 'cenW') 102 | self.matchStart, self.matchEnd = decPos(pos), decPos(endPos) 103 | range = {pos, endPos} 104 | end 105 | return range 106 | end 107 | 108 | ---may_do_command_line_next_incsearch 109 | ---@param pattern string 110 | ---@param forward boolean 111 | ---@return number[]|nil 112 | function CmdLine:incSearchPos(forward, pattern) 113 | local pos 114 | local cursor = self.matchEnd 115 | api.nvim_win_set_cursor(0, cursor) 116 | if forward then 117 | pos = utils.searchPosSafely(pattern, '') 118 | self.currentIdx = self.currentIdx == self.total and 1 or self.currentIdx + 1 119 | else 120 | utils.searchPosSafely(pattern, 'b') 121 | pos = utils.searchPosSafely(pattern, 'b') 122 | self.currentIdx = self.currentIdx == 1 and self.total or self.currentIdx - 1 123 | end 124 | api.nvim_win_set_cursor(0, cursor) 125 | if utils.comparePosition(pos, {0, 0}) > 0 then 126 | self.searchStart = self.matchStart 127 | end 128 | vim.schedule(function() 129 | self.matchStart = decPos(utils.searchPosSafely(pattern, 'bcnW')) 130 | self.matchEnd = api.nvim_win_get_cursor(0) 131 | end) 132 | return pos 133 | end 134 | 135 | function CmdLine:toggleHlSearch(enable) 136 | if not self.attached then 137 | return 138 | end 139 | if enable then 140 | cmd('if !&hlsearch | noa set hlsearch | end') 141 | else 142 | cmd('noa set nohlsearch') 143 | end 144 | end 145 | 146 | function CmdLine:attach(typ) 147 | self.attached = self.incSearch and (typ == '/' or typ == '?' or typ == ':') and 148 | vim.o.hls and vim.o.is 149 | if not self.attached then 150 | return 151 | end 152 | self.type = typ 153 | 154 | if vim.wo.foldenable then 155 | local fdo = vim.o.fdo 156 | if fdo:find('search', 1, true) or fdo:find('all', 1, true) then 157 | self.fold = fold:new() 158 | end 159 | end 160 | 161 | self:resetState() 162 | local cursor = api.nvim_win_get_cursor(0) 163 | self.parser = parser:new(typ, cursor) 164 | self.range = nil 165 | self.searchStart = cursor 166 | self.matchStart = cursor 167 | self.matchEnd = cursor 168 | self.isSubstitute = false 169 | self.searching = true 170 | self.keyCode = {} 171 | vim.on_key(function(char) 172 | if not self.searching then 173 | return 174 | end 175 | local b1, b2, b3 = char:byte(1, -1) 176 | self.keyCode = {b1, b2, b3} 177 | -- = 0x7 178 | -- = 0x14 179 | if b2 == nil and self.currentIdx > 0 and self.total > 0 and (b1 == 0x07 or b1 == 0x14) then 180 | -- TODO 181 | -- %s/pat is buggy here 182 | -- Hack! Type will get rid of incsearch issue for substitute. 183 | -- 1. Disable incsearch and current or will be appended to the cursor 184 | -- 2. Delete the appended char; 185 | -- 3. Get rid of the issue; 186 | -- 4. Redo the previous cancelled action for incsearch. 187 | -- + b1 188 | if self.isSubstitute then 189 | cmd('noa set nois') 190 | -- ignore `CmdlineChanged` event to avoid to parse cmdline recursively 191 | self.attached = false 192 | vim.schedule(function() 193 | self.attached = true 194 | cmd('noa set is') 195 | api.nvim_feedkeys(('%c%c%c%c'):format(0x08, 0x14, 0x07, b1), 'in', false) 196 | end) 197 | self.isSubstitute = nil 198 | else 199 | local pos = self:incSearchPos(b1 == 0x07, self.parser.pattern) 200 | if pos and not self.parser:hasOffset() then 201 | self:doRender(pos) 202 | end 203 | end 204 | end 205 | end, self.ns) 206 | self.debouncedSearch:cancel() 207 | end 208 | 209 | function CmdLine:didChange() 210 | self.searching = self.parser:doParse() 211 | if not self.searching then 212 | return 213 | end 214 | if self.parser.type == ':' then 215 | self.isSubstitute = self.parser:isSubstitute() 216 | self.range = self.parser.range 217 | if self.isSubstitute then 218 | render.clear(true, 0, true) 219 | elseif not self.parser:patternChanged() then 220 | self.matchEnd = api.nvim_win_get_cursor(0) 221 | return 222 | end 223 | else 224 | self.parser:splitPattern() 225 | end 226 | local range 227 | if self.parser:validatePattern() then 228 | range = self:searchRange(self.parser.pattern) 229 | end 230 | if range then 231 | self:doRender(range[1], range[2]) 232 | else 233 | self:resetState() 234 | render.clear(true, 0, true) 235 | end 236 | end 237 | 238 | function CmdLine:detach(typ, abort) 239 | if not self.attached or self.type ~= typ then 240 | return 241 | end 242 | self:toggleHlSearch(true) 243 | self.attached = false 244 | self.parser = nil 245 | self.hrtime = nil 246 | vim.on_key(nil, self.ns) 247 | if self.fold then 248 | if abort then 249 | self.fold:undo() 250 | end 251 | end 252 | self.fold = nil 253 | if self.isVisualArea then 254 | render:clearVisualArea() 255 | end 256 | self.isVisualArea = false 257 | self.debouncedSearch:cancel() 258 | end 259 | 260 | function CmdLine:onChanged() 261 | if not self.attached then 262 | return 263 | end 264 | 265 | local now = uv.hrtime() 266 | local deltaTime = self.hrtime and now - self.hrtime 267 | self.hrtime = now 268 | 269 | local line = fn.getcmdline() 270 | self.parser:setLine(line) 271 | if not self.parser:lineChanged() then 272 | return 273 | end 274 | 275 | local isVisualArea = line:find([[\%V]], 1, true) ~= nil 276 | if isVisualArea ~= self.isVisualArea then 277 | if isVisualArea then 278 | render:setVisualArea() 279 | else 280 | render:clearVisualArea() 281 | end 282 | end 283 | self.isVisualArea = isVisualArea 284 | 285 | -- 10 ms is sufficient to identify whether the user is typing in command line mode or 286 | -- emitting key sequences from a key mapping 287 | if deltaTime and deltaTime < 1e7 and not self:typeUpDown() then 288 | self.debouncedSearch() 289 | if self.type ~= ':' and isVisualArea then 290 | local cursor = api.nvim_win_get_cursor(0) 291 | -- toggle hlsearch depends on whether cursor is moved or not 292 | self:toggleHlSearch(utils.comparePosition(cursor, self.searchStart) ~= 0) 293 | else 294 | vim.v.hlsearch = 0 295 | end 296 | return 297 | else 298 | self.debouncedSearch:cancel() 299 | end 300 | self:didChange() 301 | self:toggleHlSearch(not self.parser:isEmptyVisualAreaPattern()) 302 | end 303 | 304 | function CmdLine:dispose() 305 | disposable.disposeAll(self.disposables) 306 | self.disposables = {} 307 | end 308 | 309 | function CmdLine:initialize(ns) 310 | if self.initialized then 311 | return self 312 | end 313 | self.incSearch = config.enable_incsearch 314 | self.ns = ns 315 | self.debouncedSearch = debounce(function() 316 | if not self.attached or self.type ~= fn.getcmdtype() then 317 | return 318 | end 319 | self:didChange() 320 | -- ^R ^[ 321 | api.nvim_feedkeys(('%c%c'):format(0x12, 0x1b), 'in', false) 322 | end, 300) 323 | table.insert(self.disposables, disposable:create(function() 324 | self.initialized = false 325 | self.debouncedSearch:cancel() 326 | self.debouncedSearch = nil 327 | end)) 328 | event:on('CmdlineEnter', function(cmdType) 329 | self:attach(cmdType) 330 | end, self.disposables) 331 | event:on('CmdlineLeave', function(cmdType, abort) 332 | self:detach(cmdType, abort) 333 | render:start(true) 334 | end, self.disposables) 335 | event:on('CmdlineChanged', function(cmdType) 336 | self:onChanged() 337 | end, self.disposables) 338 | self.initialized = true 339 | return self 340 | end 341 | 342 | return CmdLine 343 | -------------------------------------------------------------------------------- /lua/hlslens/cmdline/parser.lua: -------------------------------------------------------------------------------- 1 | local fn = vim.fn 2 | local api = vim.api 3 | local cmd = vim.cmd 4 | 5 | ---@class HlslensCmdLineParser 6 | ---@field type string 7 | ---@field line string 8 | ---@field lastLine string 9 | ---@field name? string 10 | ---@field pattern? string 11 | ---@field range? number[] 12 | ---@field lastPattern? string 13 | ---@field offset? string 14 | ---@field originCursor number[] (1, 0)-indexed position 15 | local CmdLineParser = { 16 | builtinCmds = { 17 | substitute = 1, 18 | snomagic = 1, 19 | smagic = 1, 20 | vglobal = 2, 21 | vimgrep = 2, 22 | vimgrepadd = 2, 23 | lvimgrep = 2, 24 | lvimgrepadd = 2, 25 | global = 2, 26 | } 27 | } 28 | 29 | function CmdLineParser:new(typ, originCursor) 30 | local o = setmetatable({}, self) 31 | self.__index = self 32 | o.type = typ 33 | o.originCursor = originCursor 34 | o.line = '' 35 | o.lastLine = '' 36 | o.pattern = nil 37 | o.lastPattern = nil 38 | return o 39 | end 40 | 41 | function CmdLineParser:setLine(line) 42 | self.lastLine, self.line = self.line, line 43 | end 44 | 45 | function CmdLineParser:lineChanged() 46 | return self.lastLine ~= self.line 47 | end 48 | 49 | function CmdLineParser:setPattern(pattern) 50 | self.lastPattern, self.pattern = self.pattern, pattern 51 | end 52 | 53 | function CmdLineParser:patternChanged() 54 | return self.lastPattern ~= self.pattern 55 | end 56 | 57 | function CmdLineParser:isEmptyVisualAreaPattern() 58 | return self.pattern == [[\%V]] 59 | end 60 | 61 | function CmdLineParser:hasOffset() 62 | return self.offset ~= '' or self.multiple 63 | end 64 | 65 | function CmdLineParser:isSubstitute() 66 | return self.builtinCmds[self.name] == 1 67 | end 68 | 69 | function CmdLineParser:validatePattern() 70 | if not self.pattern or self:isSubstitute() and self.delimClosed then 71 | return false 72 | end 73 | -- \V, \%V, .., etc. 74 | if #self.pattern <= 3 then 75 | if #self.pattern < 2 or self.pattern:sub(1, 1) == [[\]] or self.pattern == '..' then 76 | return false 77 | end 78 | end 79 | return true 80 | end 81 | 82 | function CmdLineParser:doParse() 83 | if self.type == ':' then 84 | if #self.line > 200 or not api.nvim_parse_cmd then 85 | return false 86 | end 87 | local ok, parsed = pcall(api.nvim_parse_cmd, self.line, {}) 88 | if ok then 89 | if self.builtinCmds[parsed.cmd] then 90 | self.name, self.range = parsed.cmd, parsed.range 91 | if self.range == nil or vim.tbl_isempty(self.range) then 92 | if self:isSubstitute() then 93 | local lnum = self.originCursor[1] 94 | self.range = {lnum, lnum} 95 | else 96 | self.range = {1, api.nvim_buf_line_count(0)} 97 | end 98 | elseif #self.range == 1 then 99 | table.insert(self.range, self.range[1]) 100 | end 101 | end 102 | if #parsed.args == 0 or not self.name then 103 | return false 104 | end 105 | else 106 | -- TODO 107 | -- may throw error, need an extra pcall command to eat it 108 | pcall(cmd, '') 109 | return false 110 | end 111 | self:parseBuiltinCmd(parsed.args) 112 | self.offset, self.multiple = '', false 113 | else 114 | local pat 115 | pat, self.offset, self.multiple = self:splitPattern() 116 | self:setPattern(pat) 117 | end 118 | return self.pattern ~= nil 119 | end 120 | 121 | --- 122 | ---@param args string[] 123 | function CmdLineParser:parseBuiltinCmd(args) 124 | local str = table.concat(args, ' ') 125 | local firstByte = str:byte(1, 1) 126 | local opening 127 | local i = 2 128 | if self.name == 'global' then 129 | str = str:match('^%s*(.*)$') 130 | elseif not self:isSubstitute() then 131 | if 48 <= firstByte and firstByte <= 57 or 65 <= firstByte and firstByte <= 90 or 132 | 97 <= firstByte and firstByte <= 122 then 133 | opening = ' ' 134 | i = 1 135 | end 136 | end 137 | self.delimClosed = false 138 | if #str == 1 and self:isSubstitute() then 139 | self:setPattern(fn.getreg('/')) 140 | elseif #str == 2 and firstByte == str:byte(-1) then 141 | self.delimClosed = true 142 | self:setPattern(fn.getreg('/')) 143 | else 144 | opening = opening and opening or string.char(firstByte) 145 | local s = str:find(opening, 2, true) 146 | if s then 147 | self.delimClosed = true 148 | self:setPattern(str:sub(i, s - 1)) 149 | else 150 | self:setPattern(str:sub(i)) 151 | end 152 | end 153 | end 154 | 155 | function CmdLineParser:splitPattern() 156 | local pat 157 | local off = '' 158 | local mul = false 159 | local typ, line = self.type, self.line 160 | local delim = typ or '/' 161 | local i = 0 162 | local start = i + 1 163 | 164 | while true do 165 | i = line:find(delim, i + 1) 166 | if not i then 167 | pat = line:sub(start) 168 | break 169 | end 170 | if line:sub(i - 1, i - 1) ~= [[\]] then 171 | -- For example: "/pat/;/foo/+3;?bar" 172 | if line:sub(i + 1, i + 1) == ';' then 173 | i = i + 2 174 | start = i + 1 175 | delim = line:sub(i, i) 176 | if i <= #line then 177 | mul = true 178 | end 179 | else 180 | pat = line:sub(start, i - 1) 181 | if pat == '' then 182 | pat = fn.getreg('/') 183 | end 184 | off = line:sub(i + 1) 185 | break 186 | end 187 | end 188 | end 189 | return pat, off, mul 190 | end 191 | 192 | return CmdLineParser 193 | -------------------------------------------------------------------------------- /lua/hlslens/config.lua: -------------------------------------------------------------------------------- 1 | local config = {} 2 | 3 | local function init() 4 | local hlslens = require('hlslens') 5 | config = vim.tbl_deep_extend('keep', hlslens._config or {}, { 6 | auto_enable = true, 7 | enable_incsearch = true, 8 | calm_down = false, 9 | nearest_only = false, 10 | nearest_float_when = 'auto', 11 | float_shadow_blend = 50, 12 | virt_priority = 100, 13 | build_position_cb = nil, 14 | override_lens = nil 15 | }) 16 | hlslens._config = nil 17 | end 18 | 19 | init() 20 | 21 | return config 22 | -------------------------------------------------------------------------------- /lua/hlslens/decorator.lua: -------------------------------------------------------------------------------- 1 | local disposable = require('hlslens.lib.disposable') 2 | local event = require('hlslens.lib.event') 3 | local api = vim.api 4 | 5 | local Decorator = { 6 | initialized = false, 7 | disposables = {} 8 | } 9 | 10 | ---@diagnostic disable-next-line: unused-local 11 | local function onStart(name, tick) 12 | if api.nvim_get_mode().mode == 't' then 13 | return false 14 | end 15 | local self = Decorator 16 | self.winid = api.nvim_get_current_win() 17 | end 18 | 19 | ---@diagnostic disable-next-line: unused-local 20 | local function onWin(name, winid, bufnr, topRow, botRow) 21 | local self = Decorator 22 | if self.winid ~= winid then 23 | return false 24 | end 25 | local winWidth = api.nvim_win_get_width(winid) 26 | if not (bufnr == self.bufnr and topRow == self.topRow and botRow == self.botRow and 27 | winWidth == self.winWidth) then 28 | -- if window contained empty lines at the bottom, like scrolled up near the last line, 29 | -- closing fold may make topRow == self.topRow and botRow == self.botRow. 30 | event:emit('RegionChanged') 31 | -- event:emit('RegionChanged', bufnr, winid, topRow, botRow, winWidth) 32 | end 33 | self.bufnr, self.topRow, self.botRow, self.winWidth = bufnr, topRow, botRow, winWidth 34 | return false 35 | end 36 | 37 | ---@diagnostic disable-next-line: unused-local 38 | local function onEnd(name) 39 | if vim.v.hlsearch == 0 then 40 | event:emit('HlSearchCleared') 41 | end 42 | end 43 | 44 | function Decorator:initialize(namespace) 45 | if self.initialized then 46 | return self 47 | end 48 | self.ns = namespace 49 | api.nvim_set_decoration_provider(self.ns, { 50 | on_start = onStart, 51 | on_win = onWin, 52 | on_end = onEnd 53 | }) 54 | table.insert(self.disposables, disposable:create(function() 55 | api.nvim_set_decoration_provider(self.ns, {}) 56 | self.initialized = false 57 | end)) 58 | self.initialized = true 59 | return self 60 | end 61 | 62 | function Decorator:dispose() 63 | disposable.disposeAll(self.disposables) 64 | self.disposables = {} 65 | end 66 | 67 | return Decorator 68 | -------------------------------------------------------------------------------- /lua/hlslens/ext/ufo.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local fn = vim.fn 3 | local cmd = vim.cmd 4 | 5 | local render = require('hlslens.render') 6 | local utils = require('hlslens.utils') 7 | local event = require('hlslens.lib.event') 8 | local disposable = require('hlslens.lib.disposable') 9 | 10 | ---@class HlslensExternalUfo 11 | ---@field winid number 12 | ---@field auGroupId? number 13 | ---@field module? table 14 | ---@field initialized boolean 15 | ---@field disposables HlslensDisposable[] 16 | local Ufo = { 17 | disposables = {} 18 | } 19 | 20 | function Ufo:listVirtTextInfos(bufnr, row, endRow) 21 | local marks = api.nvim_buf_get_extmarks(bufnr, self.ns, {row, 0}, {endRow, -1}, {details = true}) 22 | local res = {} 23 | local lastRow, lastEndRow = -1, -1 24 | for _, mark in ipairs(marks) do 25 | local details = mark[4] 26 | local sr, er = mark[2], details.end_row 27 | if sr and er and (sr < lastRow or er > lastEndRow) then 28 | table.insert(res, { 29 | row = sr, 30 | endRow = er, 31 | priority = details.priority, 32 | virtText = details.virt_text 33 | }) 34 | lastRow, lastEndRow = sr, er 35 | end 36 | end 37 | return res 38 | end 39 | 40 | function Ufo:virtTextWidth(virtText) 41 | local width = 0 42 | for _, chunk in ipairs(virtText) do 43 | local text = chunk[1] 44 | width = width + fn.strdisplaywidth(text) 45 | end 46 | return width 47 | end 48 | 49 | local function calibratePos(pos, offsetLnum) 50 | return {pos[1] - offsetLnum + 1, pos[2]} 51 | end 52 | 53 | --- 54 | ---@param char string|'n'|'N' 55 | ---@param ... any 56 | ---@return boolean, number 57 | function Ufo:nN(char, ...) 58 | vim.validate({char = {char, function(c) return c == 'n' or c == 'N' end, [['n' or 'N']]}}) 59 | local winid 60 | local ok, msg = pcall(cmd, 'norm!' .. vim.v.count1 .. char) 61 | if not ok then 62 | ---@diagnostic disable-next-line: need-check-nil 63 | api.nvim_echo({{msg:match('(E%d+:.*)$'), 'ErrorMsg'}}, false, {}) 64 | return ok, winid 65 | end 66 | if self.module then 67 | winid = self.module.peekFoldedLinesUnderCursor(...) 68 | self.winid = winid 69 | if utils.isWinValid(self.winid) then 70 | local bufnr = api.nvim_win_get_buf(self.winid) 71 | api.nvim_create_autocmd('WinClosed', { 72 | group = self.auGroupId, 73 | buffer = bufnr, 74 | once = true, 75 | callback = function(ev) 76 | event:emit('UfoPreviewClosed', ev.buf) 77 | end 78 | }) 79 | end 80 | end 81 | return require('hlslens').start(), winid 82 | end 83 | 84 | function Ufo:decoratePeekWindow(winid, sList, eList, idx) 85 | local pos = sList[idx] 86 | local foldedLnum = utils.foldClosed(winid, pos[1]) 87 | if self.winid ~= winid then 88 | local w = self.winid 89 | vim.schedule(function() 90 | if self.winid == w and utils.isWinValid(self.winid) then 91 | local sp = calibratePos(sList[idx], foldedLnum) 92 | local ep = calibratePos(eList[idx], foldedLnum) 93 | local bufnr = api.nvim_win_get_buf(self.winid) 94 | render.clear(true, bufnr, true) 95 | render.addWinHighlight(self.winid, sp, ep) 96 | render:addNearestLens(bufnr, sp, idx, #sList) 97 | end 98 | end) 99 | end 100 | end 101 | 102 | function Ufo:dispose() 103 | disposable.disposeAll(self.disposables) 104 | self.disposables = {} 105 | end 106 | 107 | function Ufo:initialize(module) 108 | if self.initialized then 109 | return self 110 | end 111 | self.module = module 112 | self.ns = api.nvim_create_namespace('ufo') 113 | self.winid = -1 114 | self.auGroupId = api.nvim_create_augroup('HlSearchLensUfoPreview', {}) 115 | local disposables = {} 116 | table.insert(disposables, disposable:create(function() 117 | self.winid = -1 118 | self.initialized = false 119 | self.module = nil 120 | if self.auGroupId then 121 | api.nvim_del_augroup_by_id(self.auGroupId) 122 | self.auGroupId = nil 123 | end 124 | end)) 125 | event:on('LensUpdated', function(bufnr, pattern, changedtick, sList, eList, idx, rIdx, region) 126 | local winid = fn.bufwinid(bufnr) 127 | if #sList == 0 or not utils.isWinValid(winid) or not vim.wo[winid].foldenable then 128 | return 129 | end 130 | self:decoratePeekWindow(winid, sList, eList, idx) 131 | local lnum, endLnum = region[1], region[2] 132 | local virtTextInfos = self:listVirtTextInfos(bufnr, lnum - 1, endLnum - 1) 133 | if #virtTextInfos == 0 then 134 | return 135 | end 136 | local curLnum = api.nvim_win_get_cursor(winid)[1] 137 | local curFoldLnum = utils.foldClosed(winid, curLnum) 138 | local curRow = (curFoldLnum > 0 and curFoldLnum or curLnum) - 1 139 | local lineWidth = utils.lineWidth(winid) 140 | for _, textInfo in ipairs(virtTextInfos) do 141 | local s, e, virtText = textInfo.row, textInfo.endRow, textInfo.virtText 142 | local hlsTextInfos = render:listVirtTextInfos(bufnr, s, e) 143 | local len = #hlsTextInfos 144 | if len > 0 then 145 | local hlsTextInfo = curRow <= s and hlsTextInfos[1] or hlsTextInfos[len] 146 | local hlsVirtText = hlsTextInfo.virtText 147 | -- replace `Ignore` highlight with `UfoFoldedBg` 148 | hlsVirtText[1][2] = 'UfoFoldedBg' 149 | if not virtText then 150 | virtText = require('ufo.decorator'):getVirtTextAndCloseFold(winid, s + 1) 151 | end 152 | local width = self:virtTextWidth(virtText) 153 | local hlsVirtTextWidth = self:virtTextWidth(hlsVirtText) 154 | if width + hlsVirtTextWidth >= lineWidth then 155 | local prefix = ' ⋯' 156 | table.insert(hlsVirtText, 1, {prefix, 'UfoFoldedEllipsis'}) 157 | width = lineWidth - fn.strdisplaywidth(prefix) - hlsVirtTextWidth - 1 158 | end 159 | local priority = textInfo.priority 160 | render:setVirtText(bufnr, s, hlsVirtText, { 161 | virt_text_win_col = width, 162 | priority = type(priority) == 'number' and priority + 1 or 100 163 | }) 164 | end 165 | end 166 | end, disposables) 167 | event:on('UfoPreviewClosed', function(bufnr) 168 | self.winid = -1 169 | render.clear(true, bufnr, true) 170 | end, disposables) 171 | self.disposables = disposables 172 | return self 173 | end 174 | 175 | return Ufo 176 | -------------------------------------------------------------------------------- /lua/hlslens/highlight.lua: -------------------------------------------------------------------------------- 1 | local event = require('hlslens.lib.event') 2 | local disposable = require('hlslens.lib.disposable') 3 | local utils = require('hlslens.utils') 4 | local api = vim.api 5 | 6 | ---@class HlslensHighlight 7 | local Highlight = { 8 | initialized = false, 9 | disposables = {} 10 | } 11 | local hlBlendGroups 12 | 13 | local function resetHighlightGroup() 14 | local nearHl = utils.has08() and 'CurSearch' or 'IncSearch' 15 | api.nvim_set_hl(0, 'HlSearchNear', { 16 | default = true, 17 | link = nearHl 18 | }) 19 | api.nvim_set_hl(0, 'HlSearchLens', { 20 | default = true, 21 | link = 'WildMenu' 22 | }) 23 | api.nvim_set_hl(0, 'HlSearchLensNear', { 24 | default = true, 25 | link = nearHl 26 | }) 27 | 28 | hlBlendGroups = setmetatable({Ignore = 'Ignore'}, { 29 | __index = function(tbl, hlGroup) 30 | local newHlGroup 31 | if vim.o.termguicolors then 32 | newHlGroup = 'HlSearchBlend_' .. hlGroup 33 | local hl 34 | if utils.has09() then 35 | hl = api.nvim_get_hl(0, {name = hlGroup, link = false}) 36 | else 37 | --TODO 38 | ---@diagnostic disable-next-line: deprecated 39 | hl = api.nvim_get_hl_by_name(hlGroup, true) 40 | end 41 | hl.blend = 0 42 | api.nvim_set_hl(0, newHlGroup, hl) 43 | else 44 | newHlGroup = hlGroup 45 | end 46 | rawset(tbl, hlGroup, newHlGroup) 47 | return newHlGroup 48 | end 49 | }) 50 | end 51 | 52 | function Highlight.hlBlendGroups() 53 | if not Highlight.initialized then 54 | Highlight:initialize() 55 | end 56 | return hlBlendGroups 57 | end 58 | 59 | --- 60 | ---@return HlslensHighlight 61 | function Highlight:initialize() 62 | if self.initialized then 63 | return self 64 | end 65 | self.disposables = {} 66 | event:on('ColorScheme', resetHighlightGroup, self.disposables) 67 | resetHighlightGroup() 68 | self.initialized = true 69 | return self 70 | end 71 | 72 | function Highlight:dispose() 73 | disposable.disposeAll(self.disposables) 74 | self.disposables = {} 75 | self.initialized = false 76 | end 77 | 78 | return Highlight 79 | -------------------------------------------------------------------------------- /lua/hlslens/lib/debounce.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | 3 | ---@class HlslensDebounce 4 | ---@field timer userdata 5 | ---@field fn function 6 | ---@field args table 7 | ---@field wait number 8 | ---@field leading? boolean 9 | ---@overload fun(fn: function, wait: number, leading?: boolean): HlslensDebounce 10 | local Debounce = {} 11 | 12 | --- 13 | ---@param fn function 14 | ---@param wait number 15 | ---@param leading? boolean 16 | ---@return HlslensDebounce 17 | function Debounce:new(fn, wait, leading) 18 | vim.validate({ 19 | fn = {fn, 'function'}, 20 | wait = {wait, 'number'}, 21 | leading = {leading, 'boolean', true} 22 | }) 23 | local o = setmetatable({}, self) 24 | o.timer = nil 25 | o.fn = vim.schedule_wrap(fn) 26 | o.args = nil 27 | o.wait = wait 28 | o.leading = leading 29 | return o 30 | end 31 | 32 | function Debounce:call(...) 33 | local timer = self.timer 34 | self.args = {...} 35 | if not timer then 36 | ---@type userdata 37 | timer = uv.new_timer() 38 | self.timer = timer 39 | local wait = self.wait 40 | timer:start(wait, wait, self.leading and function() 41 | self:cancel() 42 | end or function() 43 | self:flush() 44 | end) 45 | if self.leading then 46 | self.fn(...) 47 | end 48 | else 49 | timer:again() 50 | end 51 | end 52 | 53 | function Debounce:cancel() 54 | local timer = self.timer 55 | if timer then 56 | if timer:has_ref() then 57 | timer:stop() 58 | if not timer:is_closing() then 59 | timer:close() 60 | end 61 | end 62 | self.timer = nil 63 | end 64 | end 65 | 66 | function Debounce:flush() 67 | if self.timer then 68 | self:cancel() 69 | self.fn(unpack(self.args)) 70 | end 71 | end 72 | 73 | Debounce.__index = Debounce 74 | Debounce.__call = Debounce.call 75 | 76 | return setmetatable(Debounce, { 77 | __call = Debounce.new 78 | }) 79 | -------------------------------------------------------------------------------- /lua/hlslens/lib/disposable.lua: -------------------------------------------------------------------------------- 1 | ---@class HlslensDisposable 2 | ---@field func fun() 3 | local Disposable = {} 4 | 5 | --- 6 | ---@param disposables HlslensDisposable[] 7 | function Disposable.disposeAll(disposables) 8 | for _, item in ipairs(disposables) do 9 | if item.dispose then 10 | item:dispose() 11 | end 12 | end 13 | end 14 | 15 | --- 16 | ---@param func fun() 17 | ---@return HlslensDisposable 18 | function Disposable:new(func) 19 | local o = setmetatable({}, self) 20 | self.__index = self 21 | o.func = func 22 | return o 23 | end 24 | 25 | --- 26 | ---@param func fun() 27 | ---@return HlslensDisposable 28 | function Disposable:create(func) 29 | return self:new(func) 30 | end 31 | 32 | function Disposable:dispose() 33 | self.func() 34 | end 35 | 36 | return Disposable 37 | -------------------------------------------------------------------------------- /lua/hlslens/lib/event.lua: -------------------------------------------------------------------------------- 1 | local disposable = require('hlslens.lib.disposable') 2 | 3 | ---@class HlslensEvent 4 | local Event = { 5 | _collection = {} 6 | } 7 | 8 | ---@param name string 9 | ---@param listener function 10 | function Event:off(name, listener) 11 | local listeners = self._collection[name] 12 | if not listeners then 13 | return 14 | end 15 | for i = 1, #listeners do 16 | if listeners[i] == listener then 17 | table.remove(listeners, i) 18 | break 19 | end 20 | end 21 | if #listeners == 0 then 22 | self._collection[name] = nil 23 | end 24 | end 25 | 26 | ---@param name string 27 | ---@param listener function 28 | ---@param disposables? HlslensDisposable[] 29 | ---@return HlslensDisposable 30 | function Event:on(name, listener, disposables) 31 | if not self._collection[name] then 32 | self._collection[name] = {} 33 | end 34 | table.insert(self._collection[name], listener) 35 | local d = disposable:create(function() 36 | self:off(name, listener) 37 | end) 38 | if type(disposables) == 'table' then 39 | table.insert(disposables, d) 40 | end 41 | return d 42 | end 43 | 44 | ---@param name string 45 | ---@vararg any 46 | function Event:emit(name, ...) 47 | local listeners = self._collection[name] 48 | if not listeners then 49 | return 50 | end 51 | for _, listener in ipairs(listeners) do 52 | listener(...) 53 | end 54 | end 55 | 56 | return Event 57 | -------------------------------------------------------------------------------- /lua/hlslens/lib/throttle.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | 3 | ---@class HlslensThrottle 4 | ---@field timer userdata 5 | ---@field fn function 6 | ---@field pendingArgs? table 7 | ---@field limit number 8 | ---@field leading? boolean 9 | ---@field trailing? boolean 10 | ---@overload fun(fn: function, limit: number, noLeading?: boolean, noTrailing?: boolean): HlslensThrottle 11 | local Throttle = {} 12 | 13 | --- 14 | ---@param fn function 15 | ---@param limit number 16 | ---@param noLeading? boolean 17 | ---@param noTrailing? boolean 18 | ---@return HlslensThrottle 19 | function Throttle:new(fn, limit, noLeading, noTrailing) 20 | vim.validate({ 21 | fn = {fn, 'function'}, 22 | limit = {limit, 'number'}, 23 | noLeading = {noLeading, 'boolean', true}, 24 | noTrailing = {noTrailing, 'boolean', true} 25 | }) 26 | assert(not (noLeading and noTrailing), 27 | [[The values of noLeading and noTrailing can't be all true]]) 28 | local o = setmetatable({}, self) 29 | o.timer = nil 30 | o.fn = vim.schedule_wrap(fn) 31 | o.pendingArgs = nil 32 | o.limit = limit 33 | o.leading = not noLeading 34 | o.trailing = not noTrailing 35 | return o 36 | end 37 | 38 | function Throttle:call(...) 39 | local timer = self.timer 40 | if not timer then 41 | ---@type userdata 42 | timer = uv.new_timer() 43 | self.timer = timer 44 | local limit = self.limit 45 | timer:start(limit, 0, function() 46 | if self.pendingArgs then 47 | self.fn(unpack(self.pendingArgs)) 48 | end 49 | self:cancel() 50 | end) 51 | if self.leading then 52 | self.fn(...) 53 | else 54 | self.pendingArgs = {...} 55 | end 56 | else 57 | if self.trailing then 58 | self.pendingArgs = {...} 59 | end 60 | end 61 | end 62 | 63 | function Throttle:cancel() 64 | local timer = self.timer 65 | if timer then 66 | if timer:has_ref() and not timer:is_closing() then 67 | timer:close() 68 | end 69 | end 70 | self.timer = nil 71 | self.pendingArgs = nil 72 | end 73 | 74 | Throttle.__index = Throttle 75 | Throttle.__call = Throttle.call 76 | 77 | return setmetatable(Throttle, { 78 | __call = Throttle.new 79 | }) 80 | -------------------------------------------------------------------------------- /lua/hlslens/main.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local api = vim.api 3 | local fn = vim.fn 4 | 5 | local event = require('hlslens.lib.event') 6 | local cmdl = require('hlslens.cmdline') 7 | local render = require('hlslens.render') 8 | local position = require('hlslens.position') 9 | local highlight = require('hlslens.highlight') 10 | local disposable = require('hlslens.lib.disposable') 11 | 12 | local enabled = false 13 | 14 | local disposables = {} 15 | 16 | local function createCommand() 17 | api.nvim_create_user_command('HlSearchLensToggle', function() 18 | return require('hlslens').toggle() 19 | end, {}) 20 | api.nvim_create_user_command('HlSearchLensEnable', function() 21 | return require('hlslens').enable() 22 | end, {}) 23 | api.nvim_create_user_command('HlSearchLensDisable', function() 24 | return require('hlslens').disable() 25 | end, {}) 26 | end 27 | 28 | local function createEvents() 29 | local gid = api.nvim_create_augroup('HlSearchLens', {}) 30 | api.nvim_create_autocmd({'CmdlineEnter', 'CmdlineLeave', 'CmdlineChanged'}, { 31 | pattern = {'/', '?', ':'}, 32 | group = gid, 33 | callback = function(ev) 34 | local e, cchar = ev.event, ev.file 35 | if e == 'CmdlineLeave' then 36 | event:emit(e, cchar, vim.v.event.abort) 37 | else 38 | event:emit(e, cchar) 39 | end 40 | end 41 | }) 42 | return disposable:create(function() 43 | api.nvim_del_augroup_by_id(gid) 44 | end) 45 | end 46 | 47 | function M.enable() 48 | if enabled then 49 | return false 50 | end 51 | enabled = true 52 | local ns = api.nvim_create_namespace('hlslens') 53 | createCommand() 54 | disposables = {} 55 | table.insert(disposables, createEvents()) 56 | table.insert(disposables, highlight:initialize()) 57 | table.insert(disposables, position:initialize()) 58 | table.insert(disposables, render:initialize(ns)) 59 | table.insert(disposables, cmdl:initialize(ns)) 60 | local ok, res = pcall(require, 'ufo') 61 | if ok then 62 | table.insert(disposables, require('hlslens.ext.ufo'):initialize(res)) 63 | end 64 | 65 | if vim.v.hlsearch == 1 and fn.getreg('/') ~= '' then 66 | render:start() 67 | end 68 | return true 69 | end 70 | 71 | function M.disable() 72 | if not enabled then 73 | return false 74 | end 75 | disposable.disposeAll(disposables) 76 | disposables = {} 77 | enabled = false 78 | return true 79 | end 80 | 81 | function M.isEnabled() 82 | return enabled 83 | end 84 | 85 | function M.start() 86 | if enabled then 87 | render:start() 88 | end 89 | return enabled 90 | end 91 | 92 | function M.stop() 93 | if enabled then 94 | render:stop() 95 | end 96 | return enabled 97 | end 98 | 99 | function M.exportToQuickfix(isLocation) 100 | if not enabled then 101 | return false 102 | end 103 | return require('hlslens.qf').exportRanges(isLocation) 104 | end 105 | 106 | return M 107 | -------------------------------------------------------------------------------- /lua/hlslens/position/init.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local fn = vim.fn 3 | 4 | local config = require('hlslens.config') 5 | local utils = require('hlslens.utils') 6 | 7 | ---@class HlslensPosition 8 | ---@field bufnr number 9 | ---@field changedtick number 10 | ---@field pattern string 11 | ---@field pool table 12 | ---@field poolCount number 13 | ---@field sList table 14 | ---@field eList table 15 | ---@field nearestIdx number 16 | ---@field nearestRelIdx number 17 | ---@field searchForward boolean 18 | ---@field foldedLine number 19 | ---@field visualAreaStart? number[] 20 | ---@field visualAreaEnd? number[] 21 | local Position = { 22 | initialized = false 23 | } 24 | 25 | function Position:new(bufnr, changedtick, pattern) 26 | local o = setmetatable({}, self) 27 | self.__index = self 28 | o.bufnr = bufnr 29 | o.changedtick = changedtick 30 | o.pattern = pattern 31 | self.pool[bufnr] = o 32 | self.poolCount = self.poolCount + 1 33 | return o 34 | end 35 | 36 | ---make sure run under current buffer 37 | ---@param bufnr? number 38 | ---@return HlslensPosition? 39 | function Position:compute(bufnr) 40 | local pattern = fn.getreg('/') 41 | if pattern == '' then 42 | return 43 | end 44 | bufnr = bufnr or api.nvim_get_current_buf() 45 | local o = self.pool[bufnr] 46 | local changedtick = api.nvim_buf_get_changedtick(bufnr) 47 | if o and o.changedtick == changedtick and o.pattern == pattern then 48 | local hit = true 49 | if pattern:find([[\%V]], 1, true) then 50 | local vs = api.nvim_buf_get_mark(bufnr, '<') 51 | local ve = api.nvim_buf_get_mark(bufnr, '>') 52 | hit = (not o.visualAreaStart or utils.comparePosition(vs, o.visualAreaStart) == 0) and 53 | (not o.visualAreaEnd or utils.comparePosition(ve, o.visualAreaEnd) == 0) 54 | o.visualAreaStart, o.visualAreaEnd = vs, ve 55 | end 56 | if hit then 57 | return o 58 | end 59 | end 60 | if not self.rangeModule.valid(pattern) then 61 | return 62 | end 63 | 64 | -- fast and simple way to prevent memory leaking :) 65 | if self.poolCount > 5 then 66 | self.pool = {} 67 | self.poolCount = 0 68 | end 69 | 70 | o = self:new(bufnr, changedtick, pattern) 71 | o.sList, o.eList = self.rangeModule.buildList(bufnr, pattern) 72 | 73 | local l = {startPos = o.sList, endPos = o.eList} 74 | -- TODO 75 | -- will remove build_position_cb 76 | l.start_pos = l.startPos 77 | l.end_pos = l.endPos 78 | if type(config.build_position_cb) == 'function' then 79 | pcall(config.build_position_cb, l, bufnr, changedtick, pattern) 80 | end 81 | 82 | return o 83 | end 84 | 85 | function Position:nearestIndex(curPos, curFoldedLnum, topl, botl) 86 | local idx = utils.binSearch(self.sList, curPos, utils.comparePosition) 87 | local len = #self.sList 88 | if idx > 0 then 89 | return idx, 0 90 | else 91 | idx = -idx - 1 92 | if idx == 0 then 93 | if curFoldedLnum > 0 and curFoldedLnum == fn.foldclosed(self.sList[1][1]) then 94 | return 1, 0 95 | else 96 | return 1, 1 97 | end 98 | elseif idx == len then 99 | return len, -1 100 | end 101 | end 102 | local loIdx = idx 103 | local hiIdx = idx + 1 104 | local relIdx = -1 105 | 106 | local loIdxLnum = self.sList[loIdx][1] 107 | local hiIdxLnum = self.sList[hiIdx][1] 108 | if curFoldedLnum > 0 and curFoldedLnum == fn.foldclosed(loIdxLnum) then 109 | return loIdx, 0 110 | end 111 | local foldedLnum = fn.foldclosed(hiIdxLnum) 112 | if foldedLnum > 0 then 113 | hiIdxLnum = foldedLnum 114 | if hiIdxLnum == curFoldedLnum then 115 | return hiIdx, 0 116 | end 117 | end 118 | local wv = fn.winsaveview() 119 | local loWinLine, hiWinLine = 0, 0 120 | local curWinLine = fn.winline() 121 | if topl <= loIdxLnum then 122 | api.nvim_win_set_cursor(0, {loIdxLnum, 0}) 123 | loWinLine = fn.winline() 124 | end 125 | if botl >= hiIdxLnum then 126 | api.nvim_win_set_cursor(0, {hiIdxLnum, 0}) 127 | hiWinLine = fn.winline() 128 | end 129 | fn.winrestview(wv) 130 | if hiWinLine > 0 and (loWinLine == 0 or 131 | math.ceil((hiWinLine - loWinLine) / 2) - 1 < curWinLine - loWinLine) then 132 | relIdx = 1 133 | idx = idx + 1 134 | end 135 | 136 | if relIdx == 1 and idx > 1 then 137 | -- calibrate the nearest index, because index is based on start of the position 138 | -- curPos <= previousIdxEndPos < idxStartPos maybe happened 139 | -- for instance: 140 | -- text: 1ab|c 2abc 141 | -- pattern: abc 142 | -- cursor: | 143 | -- nearest index locate at start of second 'abc', 144 | -- but current position is between start of 145 | -- previous index position and end of current index position 146 | if utils.comparePosition(curPos, self.eList[idx - 1]) <= 0 then 147 | idx = idx - 1 148 | relIdx = -1 149 | end 150 | end 151 | 152 | return idx, relIdx 153 | end 154 | 155 | local function getOffsetPos(s, e, obyte) 156 | local sl, sc = unpack(s) 157 | local el, ec = unpack(e) 158 | local ol, oc 159 | local forward = obyte > 0 160 | obyte = math.abs(obyte) 161 | if sl == el then 162 | ol = sl 163 | if forward then 164 | oc = sc + obyte 165 | if oc > ec then 166 | oc = -1 167 | end 168 | else 169 | oc = ec - obyte 170 | if oc < sc then 171 | oc = -1 172 | end 173 | end 174 | else 175 | local lines = api.nvim_buf_get_lines(0, sl - 1, el, true) 176 | local len = #lines 177 | local first = lines[1] 178 | lines[1] = first:sub(sc) 179 | local last = lines[len] 180 | lines[len] = last:sub(1, ec) 181 | if forward then 182 | ol = sl 183 | oc = sc 184 | for i = 1, len do 185 | local l = lines[i] 186 | if #l <= obyte then 187 | ol = ol + 1 188 | oc = 1 189 | obyte = obyte - #l 190 | else 191 | oc = oc + obyte 192 | break 193 | end 194 | end 195 | if ol > el then 196 | oc = -1 197 | end 198 | else 199 | ol = el 200 | for i = len, 1, -1 do 201 | local l = lines[i] 202 | if #l <= obyte then 203 | ol = ol - 1 204 | oc = -1 205 | obyte = obyte - #l 206 | else 207 | oc = #l - obyte 208 | break 209 | end 210 | end 211 | if ol == sl then 212 | oc = oc + sc - 1 213 | end 214 | end 215 | end 216 | return oc ~= -1 and {ol, oc} or nil 217 | end 218 | 219 | local function parseOffset(pattern) 220 | local off 221 | local histSearch = fn.histget('/') 222 | if histSearch ~= pattern then 223 | local delim = vim.v.searchforward == 1 and '/' or '?' 224 | local sects = vim.split(histSearch, delim) 225 | if #sects > 1 then 226 | local p = table.concat(sects, delim, 1, #sects - 1) 227 | if p == '' or p == pattern then 228 | off = sects[#sects] 229 | end 230 | end 231 | end 232 | return off 233 | end 234 | 235 | function Position:update(idx, rIdx, searchForward, foldedLine) 236 | local hit = self.nearestIdx == idx and self.nearestRelIdx == rIdx and 237 | self.searchForward == searchForward and self.foldedLine == foldedLine 238 | self.nearestIdx, self.nearestRelIdx = idx, rIdx 239 | self.searchForward, self.foldedLine = searchForward, foldedLine 240 | return hit 241 | end 242 | 243 | --- 244 | ---@param topLine number 245 | ---@param botLine number 246 | function Position:buildInfo(curPos, topLine, botLine) 247 | local foldedLine = fn.foldclosed(curPos[1]) 248 | local idx, rIdx = self:nearestIndex(curPos, foldedLine, topLine, botLine) 249 | local sp = self.sList[idx] 250 | local ep = self.eList[idx] 251 | 252 | local offsetPos 253 | local off = parseOffset(self.pattern) 254 | if off and not off ~= '' then 255 | local obyte 256 | if off:match('^e%-?') then 257 | obyte = off:match('%-%d+', 1) 258 | if not obyte and off:sub(2, 2) ~= '+' then 259 | offsetPos = ep 260 | end 261 | elseif off:match('^s%+?') and off:sub(2, 2) ~= '-' then 262 | obyte = off:match('%+%d+', 1) 263 | if not obyte then 264 | offsetPos = sp 265 | end 266 | end 267 | obyte = tonumber(obyte) 268 | if obyte then 269 | offsetPos = getOffsetPos(sp, ep, obyte) 270 | end 271 | if offsetPos then 272 | rIdx = utils.comparePosition(offsetPos, curPos) 273 | end 274 | else 275 | offsetPos = sp 276 | end 277 | self.offsetPos = offsetPos 278 | local searchForward = vim.v.searchforward == 1 279 | return self:update(idx, rIdx, searchForward, foldedLine) 280 | end 281 | 282 | function Position:cursorInRange(curPos) 283 | return utils.comparePosition(self.sList[self.nearestIdx], curPos) <= 0 and 284 | utils.comparePosition(curPos, self.eList[self.nearestIdx]) <= 0 285 | end 286 | 287 | function Position:resetPool() 288 | self.pool = {} 289 | self.poolCount = 0 290 | end 291 | 292 | function Position:dispose() 293 | self:resetPool() 294 | self.rangeModule = nil 295 | self.initialized = false 296 | end 297 | 298 | function Position:initialize() 299 | if self.initialized then 300 | return 301 | end 302 | self.pool = {} 303 | self.poolCount = 0 304 | local limit 305 | if jit then 306 | Position.rangeModule = require('hlslens.position.range.regex') 307 | limit = 1e5 308 | else 309 | Position.rangeModule = require('hlslens.position.range.qf') 310 | limit = 1e4 311 | end 312 | Position.rangeModule.initialize(limit) 313 | self.initialized = false 314 | return self 315 | end 316 | 317 | return Position 318 | -------------------------------------------------------------------------------- /lua/hlslens/position/range/qf.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local api = vim.api 4 | local fn = vim.fn 5 | local cmd = vim.cmd 6 | 7 | local utils = require('hlslens.utils') 8 | 9 | local tname 10 | local hlsQfId 11 | local limit 12 | 13 | function M.valid(pat) 14 | if pat == '' or vim.bo.bt == 'quickfix' or utils.isCmdLineWin() then 15 | return false 16 | end 17 | for g in pat:gmatch('.?/') do 18 | if g ~= [[\/]] then 19 | return false 20 | end 21 | end 22 | return true 23 | end 24 | 25 | local function getQfnrById(id) 26 | return id == 0 and 0 or fn.getqflist({id = id, nr = 0}).nr 27 | end 28 | 29 | local function keepMagicOpt(pattern) 30 | if not vim.o.magic then 31 | local foundAtom = false 32 | local i = 1 33 | while i < #pattern do 34 | if pattern:sub(i, i) == [[\]] then 35 | local atom = pattern:sub(i + 1, i + 1):upper() 36 | if atom == 'M' or atom == 'V' then 37 | foundAtom = true 38 | break 39 | else 40 | i = i + 2 41 | end 42 | else 43 | break 44 | end 45 | end 46 | if not foundAtom then 47 | pattern = [[\M]] .. pattern 48 | end 49 | end 50 | return pattern 51 | end 52 | 53 | function M.buildList(bufnr, pat) 54 | local tf 55 | if api.nvim_buf_get_name(bufnr) == '' then 56 | tf = tname 57 | cmd('f ' .. tf) 58 | end 59 | 60 | local rawPat = pat 61 | -- vimgrep can't respect magic option 62 | pat = keepMagicOpt(pat) 63 | if vim.o.smartcase then 64 | local patternChars = pat:gsub('\\.', '') 65 | if patternChars:lower() ~= patternChars then 66 | pat = '\\C' .. pat 67 | end 68 | end 69 | 70 | local originInfo = fn.getqflist({id = 0, winid = 0}) 71 | local originQfId, qwinid = originInfo.id, originInfo.winid 72 | local qfWinView 73 | if qwinid ~= 0 then 74 | qfWinView = utils.winCall(qwinid, fn.winsaveview) 75 | end 76 | 77 | local hlsQfNr = getQfnrById(hlsQfId) 78 | 79 | local grepCmd 80 | if hlsQfNr == 0 then 81 | grepCmd = 'vimgrep' 82 | else 83 | cmd(('sil noa %dchi'):format(hlsQfNr)) 84 | cmd([[noa call setqflist([], 'r')]]) 85 | grepCmd = 'vimgrepadd' 86 | end 87 | 88 | local ok, msg = pcall(cmd, ('sil noa %d%s /%s/gj %%'):format(limit + 1, grepCmd, pat)) 89 | if not ok then 90 | ---@diagnostic disable-next-line: need-check-nil 91 | if msg:match(':E682:') then 92 | ok = pcall(cmd, ('sil noa %d%s /\\V%s/gj %%'):format(limit + 1, grepCmd, pat)) 93 | end 94 | end 95 | 96 | local startPosList, endPosList = {}, {} 97 | local hlsQf = fn.getqflist({id = 0, size = 0}) 98 | hlsQfId = hlsQf.id 99 | if ok then 100 | if hlsQf.size <= limit then 101 | for _, item in ipairs(fn.getqflist()) do 102 | table.insert(startPosList, {item.lnum, item.col}) 103 | table.insert(endPosList, {item.end_lnum, item.end_col - 1}) 104 | end 105 | end 106 | end 107 | fn.setqflist({}, 'r', {title = 'hlslens pattern = ' .. rawPat}) 108 | 109 | local originNr = getQfnrById(originQfId) 110 | if originNr ~= 0 and hlsQfNr ~= originNr then 111 | local winid = fn.getqflist({winid = 0}).winid 112 | local au = (winid == 0 or hlsQfNr ~= 0) and 'noa' or '' 113 | cmd(('sil %s %dchi'):format(au, originNr)) 114 | 115 | if qfWinView then 116 | utils.winCall(qwinid, function() 117 | fn.winrestview(qfWinView) 118 | end) 119 | end 120 | end 121 | 122 | if tf then 123 | cmd('sil 0f') 124 | cmd('noa bw! ' .. tf) 125 | end 126 | 127 | return startPosList, endPosList 128 | end 129 | 130 | function M.initialize(l) 131 | hlsQfId = 0 132 | tname = fn.tempname() 133 | limit = l 134 | end 135 | 136 | return M 137 | -------------------------------------------------------------------------------- /lua/hlslens/position/range/regex.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local api = vim.api 4 | local limit 5 | 6 | local wffi = require('hlslens.wffi') 7 | local utils = require('hlslens.utils') 8 | 9 | function M.valid() 10 | return true 11 | end 12 | 13 | local function doBuild(bufnr, pat) 14 | local startPosList, endPosList = {}, {} 15 | local cnt = 0 16 | local regm = wffi.buildRegmatchT(pat) 17 | if regm then 18 | local winid = utils.getWinByBuf(bufnr) 19 | if winid == -1 then 20 | return startPosList, endPosList 21 | end 22 | 23 | local buf = wffi.getBuf(bufnr) 24 | local wp = wffi.getWin(winid) 25 | for lnum = 1, api.nvim_buf_line_count(bufnr) do 26 | local col = 0 27 | while wffi.vimRegExecMulti(buf, wp, regm, lnum, col) > 0 do 28 | cnt = cnt + 1 29 | if cnt > limit then 30 | startPosList, endPosList = {}, {} 31 | goto finish 32 | end 33 | local startPos, endPos = wffi.regmatchPos(regm) 34 | table.insert(startPosList, {startPos.lnum + lnum, startPos.col + 1}) 35 | table.insert(endPosList, {endPos.lnum + lnum, endPos.col}) 36 | 37 | if endPos.lnum > 0 then 38 | break 39 | end 40 | col = endPos.col + (col == endPos.col and 1 or 0) 41 | if col > wffi.mlGetBufLen(buf, lnum) then 42 | break 43 | end 44 | end 45 | end 46 | ::finish:: 47 | end 48 | return startPosList, endPosList 49 | end 50 | 51 | function M.buildList(bufnr, pat) 52 | return doBuild(bufnr, pat) 53 | end 54 | 55 | function M.initialize(l) 56 | limit = l 57 | jit.off(doBuild, true) 58 | end 59 | 60 | return M 61 | -------------------------------------------------------------------------------- /lua/hlslens/qf.lua: -------------------------------------------------------------------------------- 1 | local fn = vim.fn 2 | local api = vim.api 3 | local cmd = vim.cmd 4 | 5 | local position = require('hlslens.position') 6 | 7 | 8 | ---@class HlslensQuickfix 9 | local QF = {} 10 | 11 | local function setLocList(what, action) 12 | return fn.setloclist(0, {}, action or ' ', what) 13 | end 14 | 15 | local function setQfList(what, action) 16 | return fn.setqflist({}, action or ' ', what) 17 | end 18 | 19 | local function getLocList(winid, what) 20 | return fn.getloclist(winid or 0, what) 21 | end 22 | 23 | local function getQfList(what) 24 | return fn.getqflist(what) 25 | end 26 | 27 | local function qftf(qinfo) 28 | local qfBufnr = api.nvim_get_current_buf() 29 | local getListFunc = qinfo.quickfix == 1 and getQfList or function(what0) 30 | return getLocList(qinfo.winid, what0) 31 | end 32 | local qfList = getListFunc({id = qinfo.id, items = 0}) 33 | local id, items = qfList.id, qfList.items 34 | local res = {} 35 | for i = qinfo.start_idx, qinfo.end_idx do 36 | local e = items[i] 37 | table.insert(res, e.text) 38 | end 39 | vim.schedule(function() 40 | if vim.bo[qfBufnr].bt == 'quickfix' and getListFunc({id = 0}).id == id then 41 | api.nvim_buf_call(qfBufnr, function() 42 | cmd('syntax clear') 43 | end) 44 | end 45 | end) 46 | return res 47 | end 48 | 49 | --- 50 | ---@param isLocation? boolean 51 | function QF.exportRanges(isLocation) 52 | local bufnr = api.nvim_get_current_buf() 53 | local pos = position:compute(bufnr) 54 | if not pos or #pos.sList == 0 then 55 | return false 56 | end 57 | local sList, eList = pos.sList, pos.eList 58 | local cnt = #sList 59 | local startLnum, endLnum = sList[1][1], sList[cnt][1] 60 | local lines = api.nvim_buf_get_lines(bufnr, startLnum - 1, endLnum, true) 61 | local items = {} 62 | for i = 1, cnt do 63 | local lnum, col = sList[i][1], sList[i][2] 64 | local text = lines[lnum - startLnum + 1] 65 | table.insert(items, { 66 | bufnr = bufnr, 67 | lnum = lnum, 68 | col = col, 69 | end_lnum = eList[i][1], 70 | end_col = eList[i][2] + 1, 71 | text = (#text > 300 and text:sub(1, 300) .. ' ⋯' or text):gsub('%z', '^@') 72 | }) 73 | end 74 | local idx = pos.nearestIdx 75 | if not idx then 76 | local cursor = api.nvim_win_get_cursor(0) 77 | local curPos = {cursor[1], cursor[2] + 1} 78 | pos:buildInfo(curPos, fn.line('w0'), fn.line('w$')) 79 | idx = pos.nearestIdx 80 | end 81 | local what = { 82 | items = items, 83 | idx = pos.nearestIdx, 84 | title = ('hlslens bufnr: %d, pattern: %s'):format(bufnr, pos.pattern), 85 | quickfixtextfunc = qftf 86 | } 87 | local action 88 | local title = isLocation and getLocList(0, {title = 1}).title or getQfList({title = 1}).title 89 | if title:match('^hlslens bufnr:') then 90 | action = 'r' 91 | end 92 | if isLocation then 93 | setLocList(what, action) 94 | else 95 | setQfList(what, action) 96 | end 97 | return true 98 | end 99 | 100 | return QF 101 | -------------------------------------------------------------------------------- /lua/hlslens/render/extmark.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | 3 | ---@class HlslensRenderExtmark 4 | local Extmark = { 5 | bufs = {}, 6 | initialized = false 7 | } 8 | 9 | function Extmark:listVirtEol(bufnr, row, endRow) 10 | local marks = api.nvim_buf_get_extmarks(bufnr, self.ns, {row, 0}, {endRow, -1}, 11 | {details = true}) 12 | local res = {} 13 | for _, mark in ipairs(marks) do 14 | local details = mark[4] 15 | local s = mark[2] 16 | if s and details.virt_text and details.virt_text_pos == 'eol' then 17 | table.insert(res, {row = s, virtText = details.virt_text}) 18 | end 19 | end 20 | return res 21 | end 22 | 23 | function Extmark:setVirtText(bufnr, row, virtText, opts) 24 | bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr 25 | self.bufs[bufnr] = true 26 | opts = opts or {} 27 | return api.nvim_buf_set_extmark(bufnr, self.ns, row, 0, { 28 | id = opts.id, 29 | virt_text = virtText, 30 | virt_text_win_col = opts.virt_text_win_col, 31 | hl_mode = opts.hl_mode or 'combine', 32 | priority = opts.priority or self.priority 33 | }) 34 | end 35 | 36 | --- 37 | ---@param bufnr number 38 | ---@param hlGroup string 39 | ---@param start number|number[] 40 | ---@param finish number|number[] 41 | ---@param opts? table 42 | ---@return number[] 43 | function Extmark:setHighlight(bufnr, hlGroup, start, finish, opts) 44 | local function doUnPack(pos) 45 | vim.validate({ 46 | pos = {pos, 47 | function(p) 48 | local t = type(p) 49 | return t == 'table' or t == 'number' 50 | end, 'must be table or number type' 51 | } 52 | }) 53 | local row, col 54 | if type(pos) == 'table' then 55 | row, col = unpack(pos) 56 | else 57 | row = pos 58 | end 59 | col = col or 0 60 | return row, col 61 | end 62 | 63 | local function rangeToRegion(row, col, endRow, endCol) 64 | local region = {} 65 | if row > endRow or (row == endRow and col >= endCol) then 66 | return region 67 | end 68 | if row == endRow then 69 | region[row] = {col, endCol} 70 | return region 71 | end 72 | region[row] = {col, -1} 73 | for i = row + 1, endRow - 1 do 74 | region[i] = {0, -1} 75 | end 76 | if endCol > 0 then 77 | region[endRow] = {0, endCol} 78 | end 79 | return region 80 | end 81 | 82 | local row, col = doUnPack(start) 83 | local endRow, endCol = doUnPack(finish) 84 | local o = opts and vim.deepcopy(opts) or {} 85 | o.hl_group = hlGroup 86 | local ids = {} 87 | local region = rangeToRegion(row, col, endRow, endCol) 88 | for sr, range in pairs(region) do 89 | local sc, ec = range[1], range[2] 90 | local er 91 | if ec == -1 or ec == 2147483647 then 92 | er = sr + 1 93 | ec = 0 94 | end 95 | o.end_row = er 96 | o.end_col = ec 97 | table.insert(ids, api.nvim_buf_set_extmark(bufnr, self.hlNs, sr, sc, o)) 98 | end 99 | return ids 100 | end 101 | 102 | function Extmark:clearHighlight(bufnr) 103 | api.nvim_buf_clear_namespace(bufnr, self.hlNs, 0, -1) 104 | end 105 | 106 | function Extmark:clearBuf(bufnr) 107 | if not bufnr then 108 | return 109 | end 110 | bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr 111 | if self.bufs[bufnr] then 112 | if api.nvim_buf_is_valid(bufnr) then 113 | api.nvim_buf_clear_namespace(bufnr, self.ns, 0, -1) 114 | end 115 | self.bufs[bufnr] = nil 116 | end 117 | end 118 | 119 | function Extmark:clearAll() 120 | for bufnr in pairs(self.bufs) do 121 | self:clearBuf(bufnr) 122 | end 123 | self.bufs = {} 124 | end 125 | 126 | function Extmark:dispose() 127 | self:clearAll() 128 | self.initialized = false 129 | end 130 | 131 | function Extmark:initialize(namespace, priority) 132 | if self.initialized then 133 | return self 134 | end 135 | self.ns = namespace 136 | self.hlNs = self.hlNs or api.nvim_create_namespace('') 137 | self.priority = priority 138 | self.bufs = {} 139 | self.initialized = true 140 | return self 141 | end 142 | 143 | return Extmark 144 | -------------------------------------------------------------------------------- /lua/hlslens/render/floatwin.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local fn = vim.fn 3 | 4 | local utils = require('hlslens.utils') 5 | local highlight = require('hlslens.highlight') 6 | local extmark = require('hlslens.render.extmark') 7 | 8 | ---@class HlslensRenderFloatWin 9 | ---@field initialized boolean 10 | ---@field winid number 11 | ---@field bufnr number 12 | ---@field shadowBlend number 13 | local FloatWin = { 14 | initialized = false 15 | } 16 | 17 | function FloatWin:close() 18 | local ok = true 19 | if utils.isWinValid(self.winid) then 20 | -- suppress error in cmdwin 21 | ok = pcall(api.nvim_win_close, self.winid, true) 22 | end 23 | if ok then 24 | self.winid = nil 25 | end 26 | end 27 | 28 | function FloatWin.getConfig(winid) 29 | local config = api.nvim_win_get_config(winid) 30 | if config.relative == '' then 31 | return 32 | end 33 | local row, col = config.row, config.col 34 | -- row and col are a table value converted from the floating-point 35 | if type(row) == 'table' then 36 | ---@diagnostic disable-next-line: need-check-nil, inject-field 37 | config.row, config.col = tonumber(row[vim.val_idx]), tonumber(col[vim.val_idx]) 38 | end 39 | return config 40 | end 41 | 42 | local function borderHasBottomLine(border) 43 | if border == nil then 44 | return false 45 | end 46 | 47 | local s = border[6] 48 | if type(s) == 'string' then 49 | return s ~= '' 50 | else 51 | return s[1] ~= '' 52 | end 53 | end 54 | 55 | function FloatWin:open(winid, row, col, width) 56 | local conf = { 57 | win = winid, 58 | relative = 'win', 59 | width = math.max(1, width), 60 | height = 1, 61 | row = row, 62 | col = col, 63 | focusable = false, 64 | style = 'minimal', 65 | zindex = 150 66 | } 67 | if vim.fn.has('nvim-0.11.0') == 1 then 68 | conf.border = 'none' 69 | end 70 | if utils.isWinValid(self.winid) then 71 | self.bufnr = api.nvim_win_get_buf(self.winid) 72 | api.nvim_win_set_config(self.winid, conf) 73 | else 74 | self.bufnr = api.nvim_create_buf(false, true) 75 | vim.bo[self.bufnr].bufhidden = 'wipe' 76 | conf.noautocmd = true 77 | self.winid = api.nvim_open_win(self.bufnr, false, conf) 78 | end 79 | return self.winid, self.bufnr 80 | end 81 | 82 | function FloatWin:renderLine(chunks) 83 | local sects = {} 84 | local marks = {} 85 | local i = 0 86 | for _, chunk in ipairs(chunks) do 87 | local t, hlGroup = unpack(chunk) 88 | table.insert(sects, t) 89 | if hlGroup ~= '' then 90 | table.insert(marks, {hlGroup = hlGroup, col = i, endCol = i + #t}) 91 | end 92 | i = i + #t 93 | end 94 | extmark:clearHighlight(self.bufnr) 95 | api.nvim_buf_set_lines(self.bufnr, 0, -1, true, {table.concat(sects, '')}) 96 | for _, mark in ipairs(marks) do 97 | extmark:setHighlight(self.bufnr, mark.hlGroup, {0, mark.col}, {0, mark.endCol}) 98 | end 99 | end 100 | 101 | function FloatWin:updateFloatWin(winid, pos, chunks, text, lineWidth, textOff) 102 | local width, height = api.nvim_win_get_width(winid), api.nvim_win_get_height(winid) 103 | local floatCol = utils.vcol(winid, pos) % lineWidth + textOff - 1 104 | local s, e = text:find('^%s*', 1) 105 | s = e + 1 106 | e = text:find('%s*$', s) - 1 107 | local textWidth = fn.strdisplaywidth(text:sub(s, e)) 108 | local newChunks = {} 109 | local winConfig = self.getConfig(winid) 110 | if winConfig and borderHasBottomLine(winConfig.border) or not vim.o.termguicolors then 111 | self:open(winid, height, floatCol, textWidth) 112 | vim.wo[self.winid].winbl = 0 113 | else 114 | self:open(winid, height, 0, width) 115 | vim.wo[self.winid].winbl = self.shadowBlend 116 | vim.wo[self.winid].winhl = 'Normal:StatusLine' 117 | local padding = (' '):rep(math.min(floatCol, width - textWidth)) 118 | table.insert(newChunks, {padding, ''}) 119 | end 120 | local i = 1 121 | for _, chunk in ipairs(chunks) do 122 | local t, hlGroup = unpack(chunk) 123 | local len = #t 124 | if i + len > s then 125 | if i < s then 126 | t = t:sub(s - i + 1) 127 | end 128 | if i + len - 1 > e then 129 | t = t:sub(1, e) 130 | end 131 | table.insert(newChunks, {t, highlight.hlBlendGroups()[hlGroup]}) 132 | end 133 | i = i + len 134 | if i > e then 135 | break 136 | end 137 | end 138 | self:renderLine(newChunks) 139 | end 140 | 141 | function FloatWin:dispose() 142 | self.initialized = false 143 | self:close() 144 | end 145 | 146 | function FloatWin:initialize(shadowBlend) 147 | if self.initialized then 148 | return self 149 | end 150 | self.shadowBlend = shadowBlend 151 | self.initialized = true 152 | return self 153 | end 154 | 155 | return FloatWin 156 | -------------------------------------------------------------------------------- /lua/hlslens/render/init.lua: -------------------------------------------------------------------------------- 1 | local fn = vim.fn 2 | local api = vim.api 3 | local cmd = vim.cmd 4 | 5 | local utils = require('hlslens.utils') 6 | local config = require('hlslens.config') 7 | local disposable = require('hlslens.lib.disposable') 8 | local decorator = require('hlslens.decorator') 9 | local throttle = require('hlslens.lib.throttle') 10 | local position = require('hlslens.position') 11 | local event = require('hlslens.lib.event') 12 | 13 | local winhl = require('hlslens.render.winhl') 14 | local extmark = require('hlslens.render.extmark') 15 | local floatwin = require('hlslens.render.floatwin') 16 | 17 | local DUMMY_POS 18 | 19 | ---@diagnostic disable: undefined-doc-name 20 | ---@alias HlslensRenderState 21 | ---| STOP #1 22 | ---| START #2 23 | ---| PENDING #3 24 | ---@diagnostic enable: undefined-doc-name 25 | local STOP = 1 26 | local START = 2 27 | local PENDING = 3 28 | 29 | ---@class HlslensRender 30 | ---@field initialized boolean 31 | ---@field ns number 32 | ---@field status HlslensRenderState 33 | ---@field force? boolean 34 | ---@field nearestOnly boolean 35 | ---@field nearestFloatWhen string 36 | ---@field calmDown boolean 37 | ---@field stopDisposes HlslensDisposable[] 38 | ---@field disposables HlslensDisposable[] 39 | local Render = { 40 | initialized = false, 41 | stopDisposes = {}, 42 | disposables = {} 43 | } 44 | 45 | local function chunksToText(chunks) 46 | local text = '' 47 | for _, chunk in ipairs(chunks) do 48 | text = text .. chunk[1] 49 | end 50 | return text 51 | end 52 | 53 | function Render:doNohAndStop(defer) 54 | local function f() 55 | cmd('noh') 56 | self:stop() 57 | end 58 | 59 | if defer then 60 | vim.schedule(f) 61 | else 62 | f() 63 | end 64 | end 65 | 66 | function Render:mayStop() 67 | local status = self.status 68 | if status == START then 69 | self.status = PENDING 70 | vim.schedule(function() 71 | if self.status == PENDING then 72 | self.status = status 73 | end 74 | if vim.v.hlsearch == 0 then 75 | self:stop() 76 | end 77 | end) 78 | end 79 | end 80 | 81 | local function refreshCurrentBuf() 82 | local self = Render 83 | local bufnr = api.nvim_get_current_buf() 84 | local pos = position:compute(bufnr) 85 | if not pos then 86 | self:stop() 87 | return 88 | end 89 | if #pos.sList == 0 then 90 | self.clear(true, 0, true) 91 | return 92 | end 93 | 94 | local winid = api.nvim_get_current_win() 95 | local cursor = api.nvim_win_get_cursor(winid) 96 | local curPos = {cursor[1], cursor[2] + 1} 97 | local topLine, botLine = fn.line('w0'), fn.line('w$') 98 | local hit = pos:buildInfo(curPos, topLine, botLine) 99 | if self.calmDown then 100 | if not pos:cursorInRange(curPos) then 101 | self:doNohAndStop() 102 | return 103 | end 104 | elseif not self.force and hit then 105 | return 106 | end 107 | 108 | local fs, fe = pos.foldedLine, -1 109 | if fs ~= -1 then 110 | fe = fn.foldclosedend(curPos[1]) 111 | end 112 | local idx, rIdx = pos.nearestIdx, pos.nearestRelIdx 113 | local sList, eList = pos.sList, pos.eList 114 | self.addWinHighlight(0, sList[idx], eList[idx]) 115 | self:doLens(bufnr, sList, not pos.offsetPos, idx, rIdx, {topLine, botLine}, {fs, fe}) 116 | event:emit('LensUpdated', bufnr, pos.pattern, pos.changedtick, sList, eList, idx, rIdx, 117 | {topLine, botLine}) 118 | end 119 | 120 | function Render:createEvents() 121 | local dps = {} 122 | local gid = api.nvim_create_augroup('HlSearchLensRender', {}) 123 | local events = {'CursorMoved', 'TermEnter'} 124 | if self.calmDown then 125 | table.insert(events, 'TextChanged') 126 | table.insert(events, 'TextChangedI') 127 | event:on('TextChanged', function() 128 | self:doNohAndStop(true) 129 | end, dps) 130 | event:on('TextChangedI', function() 131 | self:doNohAndStop(true) 132 | end, dps) 133 | end 134 | api.nvim_create_autocmd(events, { 135 | group = gid, 136 | callback = function(ev) 137 | event:emit(ev.event) 138 | end 139 | }) 140 | event:on('CursorMoved', self.throttledRefresh, dps) 141 | event:on('TermEnter', function() 142 | self.clear(true, 0, true) 143 | end, dps) 144 | return disposable:create(function() 145 | api.nvim_del_augroup_by_id(gid) 146 | disposable.disposeAll(dps) 147 | end) 148 | end 149 | 150 | local function enoughSizeForVirt(winid, lnum, text, lineWidth) 151 | if utils.foldClosed(winid, lnum) > 0 then 152 | return true 153 | end 154 | local endVcol = utils.vcol(winid, {lnum, '$'}) - 1 155 | local remainingVcol 156 | if vim.wo[winid].wrap then 157 | remainingVcol = lineWidth - (endVcol - 1) % lineWidth - 1 158 | else 159 | remainingVcol = math.max(0, lineWidth - endVcol) 160 | end 161 | return remainingVcol > #text 162 | end 163 | 164 | function Render:addNearestLens(bufnr, pos, idx, cnt) 165 | -- To build a dummy list for compatibility 166 | local plist = {} 167 | for _ = 1, cnt do 168 | table.insert(plist, DUMMY_POS) 169 | end 170 | plist[idx] = pos 171 | self:addLens(bufnr, plist, true, idx, 0) 172 | end 173 | 174 | -- Add lens template, can be overridden by `override_lens` 175 | ---@param bufnr number buffer number 176 | ---@param startPosList table (1,1)-indexed position 177 | ---@param nearest boolean whether nearest lens 178 | ---@param idx number nearest index in the plist 179 | ---@param relIdx number relative index, negative means before current position, positive means after 180 | function Render:addLens(bufnr, startPosList, nearest, idx, relIdx) 181 | if type(config.override_lens) == 'function' then 182 | -- export render module for hacking :) 183 | return config.override_lens(self, startPosList, nearest, idx, relIdx) 184 | end 185 | local sfw = vim.v.searchforward == 1 186 | local indicator, text, chunks 187 | local absRelIdx = math.abs(relIdx) 188 | if absRelIdx > 1 then 189 | indicator = ('%d%s'):format(absRelIdx, sfw ~= (relIdx > 1) and 'N' or 'n') 190 | elseif absRelIdx == 1 then 191 | indicator = sfw ~= (relIdx == 1) and 'N' or 'n' 192 | else 193 | indicator = '' 194 | end 195 | 196 | local lnum, col = unpack(startPosList[idx]) 197 | if nearest then 198 | local cnt = #startPosList 199 | if indicator ~= '' then 200 | text = ('[%s %d/%d]'):format(indicator, idx, cnt) 201 | else 202 | text = ('[%d/%d]'):format(idx, cnt) 203 | end 204 | chunks = {{' '}, {text, 'HlSearchLensNear'}} 205 | else 206 | text = ('[%s %d]'):format(indicator, idx) 207 | chunks = {{' '}, {text, 'HlSearchLens'}} 208 | end 209 | self.setVirt(bufnr, lnum - 1, col - 1, chunks, nearest) 210 | end 211 | 212 | function Render:listVirtTextInfos(bufnr, row, endRow) 213 | return extmark:listVirtEol(bufnr, row, endRow) 214 | end 215 | 216 | function Render:setVirtText(bufnr, row, virtText, opts) 217 | return extmark:setVirtText(bufnr, row, virtText, opts) 218 | end 219 | 220 | function Render.setVirt(bufnr, row, col, chunks, nearest) 221 | local self = Render 222 | local when = self.nearestFloatWhen 223 | local exLnum, exCol = row + 1, col + 1 224 | if nearest and (when == 'auto' or when == 'always') then 225 | if utils.isCmdLineWin() then 226 | extmark:setVirtText(bufnr, row, chunks) 227 | else 228 | local winid = fn.bufwinid(bufnr ~= 0 and bufnr or '') 229 | if winid == -1 then 230 | return 231 | end 232 | local textOff = utils.textOff(winid) 233 | local lineWidth = api.nvim_win_get_width(winid) - textOff 234 | local text = chunksToText(chunks) 235 | local pos = {exLnum, exCol} 236 | if when == 'always' then 237 | floatwin:updateFloatWin(winid, pos, chunks, text, lineWidth, textOff) 238 | else 239 | if enoughSizeForVirt(winid, exLnum, text, lineWidth) then 240 | extmark:setVirtText(bufnr, row, chunks) 241 | floatwin:close() 242 | else 243 | floatwin:updateFloatWin(winid, pos, chunks, text, lineWidth, textOff) 244 | end 245 | end 246 | end 247 | else 248 | extmark:setVirtText(bufnr, row, chunks) 249 | end 250 | end 251 | 252 | -- TODO 253 | -- compatible with old demo 254 | Render.set_virt = Render.setVirt 255 | 256 | function Render:setVisualArea() 257 | local function calibrate(pos) 258 | return {pos[1] - 1, pos[2]} 259 | end 260 | 261 | local start = calibrate(api.nvim_buf_get_mark(0, '<')) 262 | local finish = calibrate(api.nvim_buf_get_mark(0, '>')) 263 | extmark:setHighlight(0, 'Visual', start, finish) 264 | end 265 | 266 | function Render:clearVisualArea() 267 | extmark:clearHighlight(0) 268 | end 269 | 270 | function Render.addWinHighlight(winid, startPos, endPos) 271 | winhl.addHighlight(winid, startPos, endPos, 'HlSearchNear') 272 | end 273 | 274 | local function getIdxLnum(posList, i) 275 | return posList[i][1] 276 | end 277 | 278 | function Render:doLens(bufnr, startPosList, nearest, idx, relIdx, limitRange, foldRange) 279 | local posLen = #startPosList 280 | local idxLnum = getIdxLnum(startPosList, idx) 281 | 282 | local lineRenderList = {} 283 | 284 | if not self.nearestOnly and not nearest then 285 | local iLnum, rIdx 286 | local lastHlLnum = 0 287 | local topLimit, botLimit = limitRange[1], limitRange[2] 288 | local fs, fe = foldRange[1], foldRange[2] 289 | 290 | local tIdx = idx - 1 - math.min(relIdx, 0) 291 | while fs > -1 and tIdx > 0 do 292 | iLnum = getIdxLnum(startPosList, tIdx) 293 | if fs > iLnum then 294 | break 295 | end 296 | tIdx = tIdx - 1 297 | end 298 | for i = math.max(tIdx, 0), 1, -1 do 299 | iLnum = getIdxLnum(startPosList, i) 300 | if iLnum < topLimit then 301 | break 302 | end 303 | if lastHlLnum ~= iLnum then 304 | lastHlLnum = iLnum 305 | rIdx = i - tIdx - 1 306 | lineRenderList[iLnum] = {i, rIdx} 307 | end 308 | end 309 | 310 | local bIdx = idx + 1 - math.max(relIdx, 0) 311 | while fe > -1 and bIdx < posLen do 312 | iLnum = getIdxLnum(startPosList, bIdx) 313 | if fe < iLnum then 314 | break 315 | end 316 | bIdx = bIdx + 1 317 | end 318 | lastHlLnum = idxLnum 319 | local lastI 320 | for i = bIdx, posLen do 321 | lastI = i 322 | iLnum = getIdxLnum(startPosList, i) 323 | if lastHlLnum ~= iLnum then 324 | lastHlLnum = iLnum 325 | rIdx = i - bIdx 326 | lineRenderList[startPosList[i - 1][1]] = {i - 1, rIdx} 327 | end 328 | if iLnum > botLimit then 329 | break 330 | end 331 | end 332 | 333 | if lastI and iLnum <= botLimit then 334 | rIdx = lastI - bIdx + 1 335 | lineRenderList[iLnum] = {lastI, rIdx} 336 | end 337 | lineRenderList[idxLnum] = nil 338 | end 339 | 340 | extmark:clearBuf(bufnr) 341 | self:addLens(bufnr, startPosList, true, idx, relIdx) 342 | for _, idxPairs in pairs(lineRenderList) do 343 | self:addLens(bufnr, startPosList, false, idxPairs[1], idxPairs[2]) 344 | end 345 | end 346 | 347 | function Render.clear(hl, bufnr, floated) 348 | if hl then 349 | winhl.clearHighlight() 350 | end 351 | if bufnr then 352 | extmark:clearBuf(bufnr) 353 | end 354 | if floated then 355 | floatwin:close() 356 | end 357 | end 358 | 359 | function Render.clearAll() 360 | floatwin:close() 361 | extmark:clearAll() 362 | winhl.clearHighlight() 363 | end 364 | 365 | function Render:refresh(force) 366 | self.force = force or self.force 367 | self.throttledRefresh() 368 | end 369 | 370 | function Render:start(force) 371 | if vim.o.hlsearch then 372 | if self.status == STOP then 373 | self.status = START 374 | table.insert(self.stopDisposes, decorator:initialize(self.ns)) 375 | table.insert(self.stopDisposes, self:createEvents()) 376 | event:on('RegionChanged', function() 377 | self:refresh(true) 378 | end, self.stopDisposes) 379 | event:on('HlSearchCleared', function() 380 | self:mayStop() 381 | end, self.stopDisposes) 382 | table.insert(self.stopDisposes, disposable:create(function() 383 | position:resetPool() 384 | self.status = STOP 385 | self.clearAll() 386 | self.throttledRefresh:cancel() 387 | end)) 388 | end 389 | if not self.throttledRefresh then 390 | return 391 | end 392 | if force then 393 | self.throttledRefresh:cancel() 394 | end 395 | self:refresh(force) 396 | end 397 | end 398 | 399 | function Render:isStarted() 400 | return self.status == START 401 | end 402 | 403 | function Render:dispose() 404 | self:stop() 405 | disposable.disposeAll(self.disposables) 406 | self.disposables = {} 407 | end 408 | 409 | function Render:stop() 410 | disposable.disposeAll(self.stopDisposes) 411 | self.stopDisposes = {} 412 | end 413 | 414 | function Render:initialize(namespace) 415 | self.status = STOP 416 | if self.initialized then 417 | return self 418 | end 419 | self.nearestOnly = config.nearest_only 420 | self.nearestFloatWhen = config.nearest_float_when 421 | self.calmDown = config.calm_down 422 | self.throttledRefresh = throttle(function() 423 | if self.status == START and self.throttledRefresh and vim.v.hlsearch == 1 then 424 | refreshCurrentBuf() 425 | end 426 | self.force = nil 427 | end, 150) 428 | table.insert(self.disposables, disposable:create(function() 429 | self.status = STOP 430 | self.initialized = false 431 | self.throttledRefresh:cancel() 432 | self.throttledRefresh = nil 433 | end)) 434 | table.insert(self.disposables, extmark:initialize(namespace, config.virt_priority)) 435 | table.insert(self.disposables, floatwin:initialize(config.float_shadow_blend)) 436 | self.ns = namespace 437 | self.initialized = true 438 | DUMMY_POS = {1, 1} 439 | return self 440 | end 441 | 442 | return Render 443 | -------------------------------------------------------------------------------- /lua/hlslens/render/winhl.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local fn = vim.fn 4 | local api = vim.api 5 | 6 | local utils = require('hlslens.utils') 7 | local winMatchIds = {} 8 | 9 | function M.addHighlight(winid, startPos, endPos, hlGroup, priority) 10 | winid = winid == 0 and api.nvim_get_current_win() or winid 11 | M.clearHighlight() 12 | 13 | local sLnum, sCol = unpack(startPos) 14 | local eLnum, eCol = unpack(endPos) 15 | local pos 16 | if eLnum == sLnum then 17 | pos = {{sLnum, sCol, eCol - sCol + 1}} 18 | else 19 | pos = {{sLnum, sCol, vim.o.co}} 20 | for i = 1, eLnum - sLnum - 1 do 21 | table.insert(pos, {sLnum + i}) 22 | end 23 | table.insert(pos, {eLnum, 1, eCol}) 24 | end 25 | 26 | local matchids = utils.matchAddPos(hlGroup, pos, priority, winid) 27 | winMatchIds = {winid, matchids} 28 | return matchids 29 | end 30 | 31 | function M.clearHighlight() 32 | local winid, matchids = unpack(winMatchIds) 33 | if matchids then 34 | if api.nvim_win_is_valid(winid) then 35 | for _, id in ipairs(matchids) do 36 | pcall(fn.matchdelete, id, winid) 37 | end 38 | end 39 | winMatchIds = {} 40 | end 41 | end 42 | 43 | return M 44 | -------------------------------------------------------------------------------- /lua/hlslens/utils.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local fn = vim.fn 3 | local api = vim.api 4 | local cmd = vim.cmd 5 | 6 | --- 7 | ---@return fun(): boolean 8 | M.has08 = (function() 9 | local has08 10 | return function() 11 | if has08 == nil then 12 | has08 = fn.has('nvim-0.8') == 1 13 | end 14 | return has08 15 | end 16 | end)() 17 | 18 | ---@return fun(): boolean 19 | M.has09 = (function() 20 | local has09 21 | return function() 22 | if has09 == nil then 23 | has09 = fn.has('nvim-0.9') == 1 24 | end 25 | return has09 26 | end 27 | end)() 28 | 29 | ---@return fun(): boolean 30 | M.has10 = (function() 31 | local has10 32 | return function() 33 | if has10 == nil then 34 | has10 = fn.has('nvim-0.10') == 1 35 | end 36 | return has10 37 | end 38 | end)() 39 | 40 | --- 41 | ---@param winid number 42 | ---@return boolean 43 | function M.isWinValid(winid) 44 | return type(winid) == 'number' and winid > 0 and api.nvim_win_is_valid(winid) 45 | end 46 | 47 | --- 48 | ---@param items table 49 | ---@param element any 50 | ---@param comp fun(any, any) 51 | ---@return number 52 | function M.binSearch(items, element, comp) 53 | vim.validate({items = {items, 'table'}, comp = {comp, 'function'}}) 54 | local min, max, mid = 1, #items, 1 55 | local r = 0 56 | while min <= max do 57 | mid = math.floor((min + max) / 2) 58 | r = comp(items[mid], element) 59 | if r == 0 then 60 | return mid 61 | elseif r > 0 then 62 | max = mid - 1 63 | else 64 | min = mid + 1 65 | end 66 | end 67 | return -min 68 | end 69 | 70 | --- 71 | ---@param p1 number[] 72 | ---@param p2 number[] 73 | ---@return number|-1|0|1 74 | function M.comparePosition(p1, p2) 75 | if p1[1] == p2[1] then 76 | if p1[2] == p2[2] then 77 | return 0 78 | else 79 | return p1[2] > p2[2] and 1 or -1 80 | end 81 | else 82 | return p1[1] > p2[1] and 1 or -1 83 | end 84 | end 85 | 86 | function M.getWinInfo(winid) 87 | local winfos = fn.getwininfo(winid) 88 | assert(type(winfos) == 'table' and #winfos == 1, 89 | '`getwininfo` expected 1 table with single element.') 90 | return winfos[1] 91 | end 92 | 93 | --- 94 | ---@param winid number 95 | ---@return number 96 | function M.lineWidth(winid) 97 | local textOff = M.textOff(winid) 98 | return api.nvim_win_get_width(winid) - textOff 99 | end 100 | 101 | --- 102 | ---@param winid number 103 | ---@return number 104 | function M.textOff(winid) 105 | vim.validate({winid = {winid, 'number'}}) 106 | return M.getWinInfo(winid).textoff 107 | end 108 | 109 | --- 110 | ---@return boolean 111 | function M.isCmdLineWin() 112 | return fn.getcmdwintype() ~= '' 113 | end 114 | 115 | --- 116 | ---@param winid number 117 | ---@param pos number[] 118 | ---@return number 119 | function M.vcol(winid, pos) 120 | local vcol = M.winCall(winid, function() 121 | return fn.virtcol(pos) 122 | end) 123 | if not vim.wo[winid].wrap then 124 | vcol = vcol - M.winCall(winid, fn.winsaveview).leftcol 125 | end 126 | return vcol 127 | end 128 | 129 | --- 130 | ---@param winid number 131 | ---@param lnum number 132 | ---@return number 133 | function M.foldClosed(winid, lnum) 134 | return M.winCall(winid, function() 135 | return fn.foldclosed(lnum) 136 | end) 137 | end 138 | 139 | --- 140 | ---@param hlGroup string 141 | ---@param plist table 142 | ---@param prior? number 143 | ---@param winid? number 144 | ---@return number[] 145 | function M.matchAddPos(hlGroup, plist, prior, winid) 146 | vim.validate({ 147 | hlGroup = {hlGroup, 'string'}, 148 | plist = {plist, 'table'}, 149 | prior = {prior, 'number', true}, 150 | winid = {winid, 'number'} 151 | }) 152 | prior = prior or 10 153 | 154 | local ids = {} 155 | local l = {} 156 | for i, p in ipairs(plist) do 157 | table.insert(l, p) 158 | if i % 8 == 0 then 159 | table.insert(ids, fn.matchaddpos(hlGroup, l, prior, -1, {window = winid})) 160 | l = {} 161 | end 162 | end 163 | if #l > 0 then 164 | table.insert(ids, fn.matchaddpos(hlGroup, l, prior, -1, {window = winid})) 165 | end 166 | return ids 167 | end 168 | 169 | --- 170 | ---@param winid number 171 | ---@param f fun(): any 172 | ---@return ... 173 | function M.winCall(winid, f) 174 | if winid == 0 or winid == api.nvim_get_current_win() then 175 | return f() 176 | else 177 | local curWinid = api.nvim_get_current_win() 178 | local noaSetWin = 'noa call nvim_set_current_win(%d)' 179 | cmd(noaSetWin:format(winid)) 180 | local r = {pcall(f)} 181 | cmd(noaSetWin:format(curWinid)) 182 | assert(r[1], r[2]) 183 | return unpack(r, 2) 184 | end 185 | end 186 | 187 | --- 188 | ---@param pattern string 189 | ---@param flags? string 190 | ---@param stopline? number 191 | ---@param timeout? number 192 | ---@param skip? any 193 | ---@return number[] 194 | function M.searchPosSafely(pattern, flags, stopline, timeout, skip) 195 | -- TODO 196 | -- Pass `nil` to pcall with Neovim function make serialization issue, need `unpack` as a 197 | -- helper to prevent `nil` to pass. 198 | local ok, res = pcall(fn.searchpos, pattern, unpack({flags, stopline, timeout, skip})) 199 | return ok and res or {0, 0} 200 | end 201 | 202 | --- 203 | ---@param bufnr number 204 | ---@return number, number[]? 205 | function M.getWinByBuf(bufnr) 206 | local curBufnr 207 | if not bufnr then 208 | curBufnr = api.nvim_get_current_buf() 209 | bufnr = curBufnr 210 | end 211 | local winids = {} 212 | for _, winid in ipairs(api.nvim_list_wins()) do 213 | if bufnr == api.nvim_win_get_buf(winid) then 214 | table.insert(winids, winid) 215 | end 216 | end 217 | if #winids == 0 then 218 | return -1 219 | elseif #winids == 1 then 220 | return winids[1] 221 | else 222 | if not curBufnr then 223 | curBufnr = api.nvim_get_current_buf() 224 | end 225 | local winid = curBufnr == bufnr and api.nvim_get_current_win() or winids[1] 226 | return winid, winids 227 | end 228 | end 229 | 230 | return M 231 | -------------------------------------------------------------------------------- /lua/hlslens/wffi.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-field 2 | local M = {} 3 | 4 | local utils = require('hlslens.utils') 5 | local C 6 | local ffi 7 | 8 | local Cpattern 9 | local Cchar_u_VLA 10 | local Cregmmatch_T 11 | 12 | function M.getWin(winid) 13 | local err = ffi.new('Error') 14 | return C.find_window_by_handle(winid, err) 15 | end 16 | 17 | function M.getBuf(bufnr) 18 | local err = ffi.new('Error') 19 | return C.find_buffer_by_handle(bufnr, err) 20 | end 21 | 22 | function M.buildRegmatchT(pat) 23 | -- https://luajit.org/ext_ffi_semantics.html#gc 24 | -- Cpat must be referenced, it will be used during `vim_regexec_multi` 25 | Cpattern = Cchar_u_VLA(#pat + 1) 26 | ffi.copy(Cpattern, pat) 27 | 28 | local regProg = C.vim_regcomp(Cpattern, vim.o.magic and 1 or 0) 29 | -- `if not regProg then` doesn't work with cdata: NULL from C 30 | if regProg == nil then 31 | return 32 | end 33 | local regm = Cregmmatch_T() 34 | regm.regprog = regProg 35 | regm.rmm_ic = C.ignorecase(Cpattern) 36 | regm.rmm_maxcol = 0 37 | return regm 38 | end 39 | 40 | function M.regmatchPos(regm) 41 | local startPos, endPos = regm.startpos[0], regm.endpos[0] 42 | return {lnum = tonumber(startPos.lnum), col = startPos.col}, 43 | {lnum = tonumber(endPos.lnum), col = endPos.col} 44 | end 45 | 46 | function M.vimRegExecMulti(buf, wp, regm, lnum, col) 47 | return tonumber(C.vim_regexec_multi(regm, wp, buf, lnum, col, nil, nil)) 48 | end 49 | 50 | local function init() 51 | ffi = require('ffi') 52 | setmetatable(M, {__index = ffi}) 53 | C = ffi.C 54 | 55 | if utils.has08() then 56 | ffi.cdef([[ 57 | typedef int32_t linenr_T; 58 | ]]) 59 | else 60 | ffi.cdef([[ 61 | typedef long linenr_T; 62 | ]]) 63 | end 64 | ffi.cdef([[ 65 | typedef unsigned char char_u; 66 | typedef struct regprog regprog_T; 67 | 68 | typedef int colnr_T; 69 | 70 | typedef struct { 71 | linenr_T lnum; 72 | colnr_T col; 73 | } lpos_T; 74 | ]]) 75 | if utils.has09() then 76 | -- Add rmm_matchcol field to regmmatch_T 77 | -- https://github.com/neovim/neovim/commit/7e9981d246a9d46f19dc6283664c229ae2efe727 78 | ffi.cdef([[ 79 | typedef struct { 80 | regprog_T *regprog; 81 | lpos_T startpos[10]; 82 | lpos_T endpos[10]; 83 | colnr_T rmm_matchcol; 84 | int rmm_ic; 85 | colnr_T rmm_maxcol; 86 | } regmmatch_T; 87 | ]]) 88 | else 89 | ffi.cdef([[ 90 | typedef struct { 91 | regprog_T *regprog; 92 | lpos_T startpos[10]; 93 | lpos_T endpos[10]; 94 | int rmm_ic; 95 | colnr_T rmm_maxcol; 96 | } regmmatch_T; 97 | ]]) 98 | end 99 | 100 | ffi.cdef([[ 101 | typedef struct {} Error; 102 | typedef struct window_S win_T; 103 | typedef struct file_buffer buf_T; 104 | 105 | buf_T *find_buffer_by_handle(int buffer, Error *err); 106 | win_T *find_window_by_handle(int window, Error *err); 107 | 108 | regprog_T *vim_regcomp(char_u *expr_arg, int re_flags); 109 | 110 | int ignorecase(char_u *pat); 111 | 112 | long vim_regexec_multi(regmmatch_T *rmp, win_T *win, buf_T *buf, linenr_T lnum, colnr_T col, 113 | void *dummy_ptr, int *timed_out); 114 | 115 | ]]) 116 | 117 | if utils.has10() then 118 | -- https://github.com/neovim/neovim/commit/b465ede2c7a4fb39cf84682d645a3acd08631010 119 | ffi.cdef([[colnr_T ml_get_buf_len(buf_T *buf, linenr_T lnum);]]) 120 | 121 | function M.mlGetBufLen(buf, lnum) 122 | return tonumber(C.ml_get_buf_len(buf, lnum)) 123 | end 124 | else 125 | ffi.cdef([[ 126 | char_u *ml_get_buf(buf_T *buf, linenr_T lnum, bool will_change); 127 | size_t strlen(const char *s); 128 | ]]) 129 | 130 | function M.mlGetBufLen(buf, lnum) 131 | local ml = C.ml_get_buf(buf, lnum, false) 132 | return tonumber(C.strlen(ml)) 133 | end 134 | end 135 | 136 | 137 | Cchar_u_VLA = ffi.typeof('char_u[?]') 138 | Cregmmatch_T = ffi.typeof('regmmatch_T') 139 | end 140 | 141 | init() 142 | 143 | return M 144 | -------------------------------------------------------------------------------- /plugin/hlslens.vim: -------------------------------------------------------------------------------- 1 | " Actually, want to remove this file, but it will break change :( 2 | if exists('g:loaded_nvim_hlslens') 3 | finish 4 | endif 5 | 6 | if !has('nvim-0.6.1') 7 | call v:lua.vim.notify('nvim-hlslens failed to initialize, RTFM.') 8 | finish 9 | endif 10 | 11 | let g:loaded_nvim_hlslens = 1 12 | --------------------------------------------------------------------------------