├── plugin └── nvim-quick-switcher.vim ├── lua └── nvim-quick-switcher │ ├── ts.lua │ ├── util.lua │ └── init.lua ├── LICENSE └── README.md /plugin/nvim-quick-switcher.vim: -------------------------------------------------------------------------------- 1 | " fun! NvimQuickSwitcher() 2 | " lua for k in pairs(package.loaded) do if k:match("^nvim%-quick%-switcher") then package.loaded[k] = nil end end 3 | " endfun 4 | -------------------------------------------------------------------------------- /lua/nvim-quick-switcher/ts.lua: -------------------------------------------------------------------------------- 1 | -- TS Utils 2 | local M = {} 3 | 4 | local ts_utils = require 'nvim-treesitter.ts_utils' 5 | 6 | local get_root = function(bufnr, file_type) 7 | local parser = vim.treesitter.get_parser(bufnr, file_type, {}) 8 | local tree = parser:parse()[1] 9 | return tree:root() 10 | end 11 | 12 | M.go_to_node = function(file_type, query, goto_end, avoid_set_jump) 13 | local bufnr = vim.api.nvim_get_current_buf() 14 | if vim.bo[bufnr].filetype ~= file_type then 15 | return 16 | end 17 | 18 | local root = get_root(bufnr, file_type) 19 | for id, node in query:iter_captures(root, bufnr, 0, -1) do 20 | ts_utils.goto_node(node, goto_end, avoid_set_jump) 21 | return 22 | end 23 | end 24 | -- End 25 | 26 | return M 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /lua/nvim-quick-switcher/util.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.listToTable(list, filterFn) 4 | local t = {} 5 | for item in list:gmatch("[^\r\n]+") do 6 | if (not filterFn or not filterFn(item)) then 7 | table.insert(t, item); 8 | else 9 | end 10 | end 11 | return t 12 | end 13 | 14 | function M.readCmd(cmd) 15 | local handle = io.popen(cmd); 16 | local result = handle:read('*a') 17 | handle:close() 18 | return result; 19 | end 20 | 21 | function M.prop_factory(defaults, props) 22 | if props == nil then 23 | return defaults 24 | end 25 | 26 | for k, v in pairs(props) do 27 | defaults[k] = v 28 | end 29 | 30 | return defaults; 31 | end 32 | 33 | function M.resolve_prefix(path_state, option) 34 | if type(option) == "string" then 35 | if option == "short" then 36 | return path_state.short_prefix 37 | elseif option == "full" then 38 | return path_state.full_prefix 39 | elseif option == "long" then 40 | return path_state.long_prefix 41 | else 42 | return path_state.prefix 43 | end 44 | elseif type(option) == "function" then 45 | local buf_name = vim.api.nvim_buf_get_name(0) 46 | local file_name = buf_name:match(".+/(.+)%.") 47 | local file_extension = buf_name:match(".+%.(%w+)$") 48 | local custom_prefix = option(file_name, file_extension) 49 | if custom_prefix == nil then 50 | return path_state.prefix 51 | end 52 | return custom_prefix 53 | end 54 | end 55 | 56 | function M.default_inline_config() 57 | return { 58 | goto_end = false, 59 | avoid_set_jump = false, 60 | } 61 | end 62 | 63 | function M.default_find_config() 64 | return { 65 | maxdepth = 2, 66 | regex = false, 67 | path = nil, 68 | reverse = true, 69 | prefix = 'default', 70 | regex_type = 'E', 71 | ignore_prefix = false 72 | } 73 | end 74 | 75 | return M 76 | -------------------------------------------------------------------------------- /lua/nvim-quick-switcher/init.lua: -------------------------------------------------------------------------------- 1 | local util = require('nvim-quick-switcher.util') 2 | local ts = require('nvim-quick-switcher.ts') 3 | 4 | local M = {} 5 | 6 | -- Docs suggest 'vertical'|'horizontal' - anything works for horizontal. 7 | -- However, in the future I plan to add 'window', so I want to use string and not boolean 8 | local function openSplit(options) 9 | local size = options.size or ''; 10 | local direction = options.split == 'vertical' and 'vsp' or 'sp' 11 | vim.api.nvim_command(size .. direction) 12 | end 13 | 14 | local function navigation(file_name, options) 15 | local isSplit = options ~= nil and options.split ~= nil 16 | 17 | local checkIfExists = options ~= nil and options.only_existing 18 | if (checkIfExists and vim.fn.filereadable(file_name) == 0) then 19 | if (options.only_existing_notify) then vim.print(file_name .. ' does not exist.') end 20 | return; 21 | end 22 | 23 | if (isSplit) then 24 | openSplit(options); 25 | end 26 | vim.api.nvim_command('e ' .. file_name) 27 | end 28 | 29 | local function selection(items, options) 30 | return vim.ui.select( 31 | items, 32 | { prompt = 'Multiple matches found:', format_item = function(item) return item end, }, 33 | function(choice) if choice then navigation(choice, options) end end 34 | ) 35 | end 36 | 37 | local function get_path_state() 38 | local buf_name = vim.api.nvim_buf_get_name(0) 39 | local path = buf_name:match('(.+)/.+$') 40 | local file_name = buf_name:match('.+/(.+)$') 41 | local prefix = file_name:match('[%-%w_]+') 42 | local file_type = file_name:match('^.+%.(.+)$') 43 | local full_suffix = file_name:match('[%-%w_]+%.(.*)') 44 | local full_prefix = file_name:match('([%-%w_%.]+)%.%w+$') 45 | local short_prefix = file_name:match('[%w]+') 46 | local long_prefix = file_name:match("([%w_%-]+)[%-_]") 47 | return { 48 | path = path, 49 | prefix = prefix, 50 | full_prefix = full_prefix, 51 | full_suffix = full_suffix, 52 | short_prefix = short_prefix, 53 | long_prefix = long_prefix, 54 | file_type = file_type, 55 | file_name = file_name 56 | } 57 | end 58 | 59 | -- Instead of '.' config/options. Create flexible call-back function. 60 | function M.switch(suffix, user_config) 61 | local path_state = get_path_state(); 62 | local ignore_prefix = user_config ~= nil and user_config.ignore_prefix == true 63 | local prefix = path_state.prefix .. '.' 64 | if ignore_prefix then prefix = '' end 65 | return navigation(path_state.path .. '/' .. prefix .. suffix, user_config) 66 | end 67 | 68 | function M.toggle(suffixOne, suffixTwo, user_config) 69 | local path_state = get_path_state(); 70 | local suffix = suffixOne; 71 | if path_state.full_suffix == suffix then 72 | suffix = suffixTwo 73 | end 74 | 75 | return navigation(path_state.path .. '/' .. path_state.prefix .. '.' .. suffix, user_config) 76 | end 77 | 78 | function M.inline_ts_switch(file_type, query_string, user_config) 79 | local config = util.prop_factory(util.default_inline_config(), user_config) 80 | local query = vim.treesitter.parse_query(file_type, query_string) 81 | ts.go_to_node(file_type, query, config.goto_end, config.avoid_set_jump) 82 | end 83 | 84 | function M.find_by_fn(fn, user_config) 85 | local config = util.prop_factory(util.default_find_config(), user_config) 86 | local path_state = get_path_state(); 87 | local full_user_input = fn(path_state); 88 | local full_user_path = full_user_input:match('(.+)/.+$') 89 | local user_file_name = full_user_input:match('.+/(.+)$') 90 | local base_find = [[find ]] .. full_user_path .. [[ -maxdepth ]] .. config.maxdepth 91 | local name_based = ' -name ' .. [[']] .. user_file_name .. [[']] 92 | local search = base_find .. name_based 93 | local output = util.readCmd(search) 94 | local matches = util.listToTable(output, function(item) 95 | local file_name = item:match('.+/(.+)$') 96 | return path_state.file_name == file_name 97 | end); 98 | if #matches == 1 then 99 | navigation(matches[1], config) 100 | elseif #matches > 1 then 101 | selection(matches, config) 102 | end 103 | end 104 | 105 | function M.find(input, user_config) 106 | local config = util.prop_factory(util.default_find_config(), user_config) 107 | local path_state = get_path_state(); 108 | local path = config.path and config.path or path_state.path 109 | local prefix = util.resolve_prefix(path_state, config.prefix) 110 | if config.ignore_prefix then prefix = '' end 111 | local base_find = [[find ]] .. path .. [[ -maxdepth ]] .. config.maxdepth 112 | local name_based = ' -name ' .. [[']] .. prefix .. input .. [[']] 113 | local regex_based = ' -name ' .. 114 | [[']] .. prefix .. [[*']] .. [[ | grep ]] .. '-' .. config.regex_type .. [[ ']] .. input .. [[']] 115 | local search = config.regex and base_find .. regex_based or base_find .. name_based 116 | local output = util.readCmd(search) 117 | local matches = util.listToTable(output, function(item) 118 | local file_name = item:match('.+/(.+)$') 119 | return path_state.file_name == file_name 120 | end); 121 | 122 | if #matches == 1 then 123 | navigation(matches[1], config) 124 | elseif #matches > 1 then 125 | selection(matches, config) 126 | else 127 | if config.reverse then 128 | M.find(input, { maxdepth = 1, path = path .. '/..', reverse = false, regex = config.regex }) 129 | end 130 | end 131 | end 132 | 133 | return M; 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nvim Quick Switcher 2 | Quickly navigate to related/alternate files/extensions based on the current file name. Written in Lua. 3 | 4 | https://user-images.githubusercontent.com/14320878/205079483-82f2cd39-915e-485a-a5c6-20b188e1b26b.mp4 5 | 6 | ## Features 7 | - 🦕 Switch to files with common prefix (example: "tasks"): 8 | - `tasks.component.ts` --> `tasks.component.html` 9 | - `tasks.component.html` --> `tasks.component.scss` 10 | - `tasks.component.scss` --> `tasks.component.ts` 11 | - 🦎 Toggle between file extensions 12 | - `tasks.cpp` <--> `tasks.h` 13 | - `tasks.html` <--> `tasks.css` 14 | - 🐙 Switch to files with different suffix 15 | - `tasks.component.ts` --> `tasks.module.ts` 16 | - `tasks.component.ts` --> `tasks.component.spec.ts` 17 | - `tasks.query.ts` --> `tasks.store.ts` 18 | - 🐒 Find files by Wilcard (Glob) or Regex 19 | - `tasks.ts` --> `tasks.spec.ts` | `tasks.test.ts` 20 | - `tasks.ts` --> `tasks.css` | `tasks.scss` | `tasks.sass` 21 | - `/tasks.components.ts` --> `state/tasks.query.ts` 22 | - `state/tasks.query.ts` --> `../tasks.component.ts` 23 | - `controller.lua` --> `controller_spec.lua` 24 | - `controller-util.lua` --> `controller-service.lua` 25 | - 🌳 Navigate inside files with Treesitter Queries 26 | 27 | ## Installation 28 | Vim-plug 29 | ```vim 30 | Plug 'Everduin94/nvim-quick-switcher' 31 | ``` 32 | 33 | Packer 34 | ```lua 35 | use { 36 | "Everduin94/nvim-quick-switcher", 37 | } 38 | ``` 39 | 40 | ## Usage 41 | 42 | Switcher has 4 functions. `switch`, `toggle`, `find`, `inline_ts_switch`. No setup function call required. Just call a function with the desired arguments. 43 | 44 | For more examples, see `Recipes` 45 | 46 | ### ➡️ Switch 47 | *Gets "prefix" of file name, switches to `prefix`.`suffix`* 48 | 49 | `switch(search_string, options)` 50 | 51 | #### Example 52 | 53 | `require('nvim-quick-switcher').switch('component.ts')` 54 | - `ticket.component.html` --> `ticket.component.ts` 55 | 56 | #### Options 57 | ```lua 58 | { 59 | split = 'vertical'|'horizontal'|nil -- nil is default 60 | size = 100 -- # of columns | rows. default is 50% split 61 | ignore_prefix = false -- useful for navigating to files like "index.ts" or "+page.svelte" 62 | } 63 | ``` 64 | 65 | ### 🔄 Toggle 66 | *Toggles `prefix`.`suffix`, based on current suffix / file extension* 67 | 68 | `toggle(search_string_one, search_string_two, options)` 69 | 70 | For `options` see `Common Options` 71 | 72 | #### Example 73 | `require('nvim-quick-switcher').toggle('cpp', 'h')` 74 | - `node.cpp` --> `node.h` 75 | - `node.h` --> `node.cpp` 76 | 77 | ### 🔍 Find 78 | *Uses gnu/linux `find` & `grep` to find file and switch to `prefix`+`pattern`* 79 | 80 | `find(search_string, options)` 81 | 82 | #### Example 83 | `require('nvim-quick-switcher').find('*query*')` 84 | - Allows wild cards, i.e. first argument is search parameter for gnu/linux find 85 | - file: `ticket.component.ts` --> pattern: `ticket*query*` 86 | 87 | `require('nvim-quick-switcher').find('.+css|.+scss|.+sass', { regex = true })` 88 | - Uses find, then filters via grep regex, i.e. first argument is regex 89 | - file: `ticket.component.ts` --> find: `ticket*` --> grep: `.+css|.+scss|.+sass` 90 | - When using backslash, may have to escape via `\\` 91 | 92 | If multiple matches are found will prompt UI Select. 93 | - You can use options like `prefix` or `regex` to make your searches more specific. See `Recipes` for examples. 94 | 95 | If no results are found, will search backwards one directory, see `reverse` 96 | 97 | #### Options 98 | ```lua 99 | { 100 | split = 'vertical'|'horizontal'|nil 101 | size = 100 102 | regex = false, -- If true, first argument uses regex instead of find string/wildcard. 103 | maxdepth = 2, -- directory depth 104 | reverse = true, -- false will disable reverse search when no results are found 105 | path = nil, -- overwrite path (experimental). 106 | prefix = 'default', 107 | -- full: stop at last period 108 | -- short: stop at first _ or - 109 | -- long: stop at last _ 110 | -- default: stop at first period. 111 | -- lua function (you can pass a lua function to create a custom prefix) 112 | regex_type = 'E' -- default regex extended. See grep for types. 113 | ignore_prefix = false -- useful for navigating to files like "index.ts" or "+page.svelte" 114 | } 115 | ``` 116 | 117 | ### 🌳 Inline Switch (Treesitter) 118 | Requires `nvim-treesitter/nvim-treesitter` 119 | 120 | *Uses treesitter queries to navigate inside of a file* 121 | 122 | `inline_ts_switch(file_type, query_string, options)` 123 | 124 | #### Example 125 | `require('nvim-quick-switcher').inline_ts_switch('svelte', '(script_element (end_tag) @capture)')` 126 | - Places cursor at start of `` node 127 | 128 | #### Options 129 | ```lua 130 | { 131 | goto_end = false, -- go to start of node if false, go to end of node if true 132 | avoid_set_jump = false, -- do not add to jumplist if true 133 | } 134 | ``` 135 | 136 | ### 🚀 Find by Function (Advanced) 137 | Accepts a lua function that provides path/file information as an argument, and returns a file path as a string. Useful for switching across directories / folders. 138 | i.e. the user is provided file path data to then build an entire file path, that gets passed to vim select, that gets passed to navigate. 139 | 140 | `require('nvim-quick-switcher').find_by_fn(fn, options)` 141 | 142 | #### Example 143 | 144 | This example switches from a test folder to a src folder (and vice versa) like in to a Java J-Unit project. 145 | 146 | ```lua 147 | local ex_find_test_fn = function (p) 148 | local path = p.path; 149 | local file_name = p.prefix; 150 | local result = path:gsub('src', 'test') .. '/' .. file_name .. '*'; 151 | return result; 152 | end 153 | 154 | local find_src_fn = function (p) 155 | local path = p.path; 156 | local file_name = p.prefix; 157 | local result = path:gsub('test', 'src') .. '/' .. file_name .. '*'; 158 | return result; 159 | end 160 | 161 | require('nvim-quick-switcher').find_by_fn(ex_find_test_fn) 162 | require('nvim-quick-switcher').find_by_fn(find_src_fn) 163 | ``` 164 | 165 | #### Args 166 | ```lua 167 | prefix -- The prefix of the file (task.component.ts) --> task 168 | full_prefix -- (task.component.ts) --> task.component 169 | full_suffix -- (task.component.ts) --> component.ts 170 | short_prefix -- (task-util.lua) --> task 171 | file_type -- (task-util.lua) --> lua 172 | file_name -- (src/tasks/task-util.lua) --> task-util.lua 173 | path -- (src/tasks/task-util.lua) --> src/tasks/task-util.lua 174 | ``` 175 | 176 | ### 🌌 Common Options 177 | Options common for file location functions (`switch/toggle/find/find_by_fn`). 178 | ```lua 179 | { 180 | only_existing = false, 181 | only_existing_notify = false, 182 | } 183 | ``` 184 | `only_existing` Causes the switcher to check if the target file exists 185 | before switching to it. 186 | `only_existing_notify` will print if the file does not exist. 187 | 188 | 189 | ## Recipes (My Keymaps) 190 | *My configuration for nvim-quick-switcher. Written in Lua* 191 | 192 | ```lua 193 | local opts = { noremap = true, silent = true } 194 | 195 | local function find(file_regex, opts) 196 | return function() require('nvim-quick-switcher').find(file_regex, opts) end 197 | end 198 | 199 | local function inline_ts_switch(file_type, scheme) 200 | return function() require('nvim-quick-switcher').inline_ts_switch(file_type, scheme) end 201 | end 202 | 203 | local function find_by_fn(fn, opts) 204 | return function() require('nvim-quick-switcher').find_by_fn(fn, opts) end 205 | end 206 | 207 | -- Styles 208 | vim.keymap.set("n", "oi", find('.+css|.+scss|.+sass', { regex = true, prefix='full' }), opts) 209 | 210 | -- Types 211 | vim.keymap.set("n", "orm", find('.+model.ts|.+models.ts|.+types.ts', { regex = true }), opts) 212 | 213 | -- Util 214 | vim.keymap.set("n", "ol", find('*util.*', { prefix = 'short' }), opts) 215 | 216 | -- Tests 217 | vim.keymap.set("n", "ot", find('.+test|.+spec', { regex = true, prefix='full' }), opts) 218 | 219 | -- Project Specific Keymaps 220 | -- * Maps keys based on project using an auto command. Ideal for reusing keymaps based on context. 221 | -- * Example: In Angular, `oo` switches to .component.html. In Svelte, `oo` switches to *page.svelte 222 | vim.api.nvim_create_autocmd({'UIEnter'}, { 223 | callback = function(event) 224 | local is_angular = next(vim.fs.find({ "angular.json", "nx.json" }, { upward = true })) 225 | local is_svelte = next(vim.fs.find({ "svelte.config.js", "svelte.config.ts" }, { upward = true })) 226 | 227 | -- Angular 228 | if is_angular then 229 | print('Angular') 230 | vim.keymap.set("n", "oo", find('.component.html'), opts) 231 | vim.keymap.set("n", "ou", find('.component.ts'), opts) 232 | vim.keymap.set("n", "op", find('.module.ts'), opts) 233 | vim.keymap.set("n", "oy", find('.service.ts'), opts) 234 | end 235 | 236 | -- SvelteKit 237 | if is_svelte then 238 | print('Svelte') 239 | vim.keymap.set("n", "oo", find('*page.svelte', { maxdepth = 1, ignore_prefix = true }), opts) 240 | vim.keymap.set("n", "ou", find('.*page.server(.+js|.+ts)|.*page(.+js|.+ts)', { maxdepth = 1, regex = true, ignore_prefix = true }), opts) 241 | vim.keymap.set("n", "op", find('*layout.svelte', { maxdepth = 1, ignore_prefix = true }), opts) 242 | 243 | -- Inline TS 244 | vim.keymap.set("n", "oj", inline_ts_switch('svelte', '(script_element (end_tag) @capture)'), opts) 245 | vim.keymap.set("n", "ok", inline_ts_switch('svelte', '(style_element (start_tag) @capture)'), opts) 246 | end 247 | end 248 | }) 249 | 250 | -- Redux-like 251 | vim.keymap.set("n", "ore", find('*effects.ts'), opts) 252 | vim.keymap.set("n", "ora", find('*actions.ts'), opts) 253 | vim.keymap.set("n", "orw", find('*store.ts'), opts) 254 | vim.keymap.set("n", "orf", find('*facade.ts'), opts) 255 | vim.keymap.set("n", "ors", find('.+query.ts|.+selectors.ts|.+selector.ts', { regex = true }), opts) 256 | vim.keymap.set("n", "orr", find('.+reducer.ts|.+repository.ts', { regex = true }), opts) 257 | 258 | -- Java J-Unit (Advanced Example) 259 | local find_test_fn = function (p) 260 | local path = p.path; 261 | local file_name = p.prefix; 262 | local result = path:gsub('src', 'test') .. '/' .. file_name .. '*'; 263 | return result; 264 | end 265 | 266 | local find_src_fn = function (p) 267 | local path = p.path; 268 | local file_name = p.prefix; 269 | local result = path:gsub('test', 'src') .. '/' .. file_name .. '*'; 270 | return result; 271 | end 272 | 273 | vim.keymap.set("n", "ojj", find_by_fn(find_test_fn), opts) 274 | vim.keymap.set("n", "ojk", find_by_fn(find_src_fn), opts) 275 | ``` 276 | 277 | ## Personal Motivation 278 | Many moons ago, as a sweet summer child, I used VS Code with an extension called "Angular Switcher". 279 | Angular switcher enables switching to various file extensions related to the current component. 280 | 281 | I wanted to take that idea and make it work for many frameworks or file extensions 282 | 283 | I currently use nvim-quick-switcher on a daily basis for Svelte / Svelte-Kit, Angular Components, Tests, Stylesheets, Lua util files, and Redux-like files. 284 | 285 | ## Alternatives 286 | - [projectionist](https://github.com/tpope/vim-projectionist) 287 | --------------------------------------------------------------------------------