├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .stylua.toml ├── Makefile ├── README.md ├── lua ├── regexplainer.lua └── regexplainer │ ├── buffers │ ├── init.lua │ ├── popup.lua │ ├── register.lua │ ├── shared.lua │ └── split.lua │ ├── component │ ├── descriptions.lua │ ├── init.lua │ └── predicates.lua │ ├── renderers │ ├── debug │ │ └── init.lua │ ├── init.lua │ └── narrative │ │ ├── init.lua │ │ └── narrative.lua │ └── utils │ ├── init.lua │ └── treesitter.lua ├── plugin └── nvim-regexplainer.lua ├── queries ├── ecma │ └── regexplainer.scm ├── javascript │ └── regexplainer.scm ├── regex │ └── regexplainer.scm └── typescript │ └── regexplainer.scm └── tests ├── fixtures └── narrative │ ├── 01 Simple Patterns.js │ ├── 02 Modifiers.js │ ├── 03 Ranges and Quantifiers.js │ ├── 04 Negated Ranges.js │ ├── 05 Capture Groups.js │ ├── 06 Named Capture Groups.js │ ├── 07 Non-Capturing Groups.js │ ├── 08 Alternations.js │ ├── 09 Lookaround.js │ ├── 10 Special Characters.js │ ├── 11 Practical Examples.js │ ├── 12 Regex Sudoku.js │ └── 13 Errors.js ├── helpers └── util.lua ├── lua └── ansicolors.lua ├── mininit.lua ├── queries └── javascript │ └── regexplainer_test.scm └── regexplainer └── nvim-regexplainer_spec.lua /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bennypowers] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - synchronize 7 | - ready_for_review 8 | - auto_merge_enabled 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | test: 15 | name: test 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | nvim-versions: 20 | - stable 21 | - nightly 22 | steps: 23 | - name: checkout 24 | uses: actions/checkout@v2 25 | 26 | - name: Install Neovim 27 | uses: rhysd/action-setup-vim@v1 28 | with: 29 | neovim: true 30 | version: ${{ matrix.nvim-versions }} 31 | 32 | - name: Test 33 | run: make test 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .tests 3 | 4 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 2 5 | quote_style = "AutoPreferSingle" 6 | no_call_parentheses = true 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=/usr/bin/env bash 2 | .PHONY: test run_tests watch ci unload 3 | 4 | clean: 5 | @rm -rf vendor 6 | 7 | watch: 8 | @echo "Testing..." 9 | @find . \ 10 | -type f \ 11 | -name '*.lua' \ 12 | -o -name '*.js' \ 13 | ! -path "./.tests/**/*" | entr -d make test 14 | 15 | test: 16 | @REGEXPLAINER_DEBOUNCE=false \ 17 | nvim \ 18 | --headless \ 19 | --noplugin \ 20 | -u tests/mininit.lua \ 21 | -c "PlenaryBustedDirectory tests/regexplainer/ {minimal_init='tests/mininit.lua',sequential=true,keep_going=false}"\ 22 | -c "qa!" 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Am Yisrael Chai - עם ישראל חי](https://bennypowers.dev/assets/flag.am.yisrael.chai.png) 2 | 3 | # nvim-regexplainer 4 | 5 | ![Lua][made-with-lua] 6 | ![GitHub Workflow Status][build-status] 7 | [![Number of users on dotfyle][dotfyle-badge]][dotfyle] 8 | 9 | Describe the regular expression under the cursor. 10 | 11 | https://user-images.githubusercontent.com/1466420/156946492-a05600dc-0a5b-49e6-9ad2-417a403909a8.mov 12 | 13 | Heavily inspired by the venerable [atom-regexp-railroad][atom-regexp-railroad]. 14 | 15 | > 👉 **NOTE**: Requires Neovim 0.7 👈 16 | 17 | ## 🚚 Installation 18 | 19 | ```lua 20 | use { 'bennypowers/nvim-regexplainer', 21 | config = function() require'regexplainer'.setup() end, 22 | requires = { 23 | 'nvim-treesitter/nvim-treesitter', 24 | 'MunifTanjim/nui.nvim', 25 | } } 26 | ``` 27 | 28 | You need to install `regex` with `nvim-treesitter`, as well as the grammar for 29 | whichever host language you're using. So for example if you wish to use 30 | Regexplainer with TypeScript sources, you need to do this: 31 | 32 | ```vimscript 33 | :TSInstall regex typescript 34 | ``` 35 | 36 | ## 🤔 Config 37 | 38 | ```lua 39 | -- defaults 40 | require'regexplainer'.setup { 41 | -- 'narrative' 42 | mode = 'narrative', -- TODO: 'ascii', 'graphical' 43 | 44 | -- automatically show the explainer when the cursor enters a regexp 45 | auto = false, 46 | 47 | -- filetypes (i.e. extensions) in which to run the autocommand 48 | filetypes = { 49 | 'html', 50 | 'js', 51 | 'cjs', 52 | 'mjs', 53 | 'ts', 54 | 'jsx', 55 | 'tsx', 56 | 'cjsx', 57 | 'mjsx', 58 | }, 59 | 60 | -- Whether to log debug messages 61 | debug = false, 62 | 63 | -- 'split', 'popup' 64 | display = 'popup', 65 | 66 | mappings = { 67 | toggle = 'gR', 68 | -- examples, not defaults: 69 | -- show = 'gS', 70 | -- hide = 'gH', 71 | -- show_split = 'gP', 72 | -- show_popup = 'gU', 73 | }, 74 | 75 | narrative = { 76 | indendation_string = '> ', -- default ' ' 77 | }, 78 | } 79 | ``` 80 | 81 | ### `display` 82 | 83 | Regexplainer offers a small variety of display modes to suit your preferences. 84 | 85 | #### Split Window 86 | 87 | Set to `split` to display the explainer in a window below the editor. 88 | The window will be reused, and has the filetype `Regexplainer` 89 | 90 | #### Popup Below Cursor 91 | 92 | Set to `popup` (the default) to display the explainer in a popup below the 93 | cursor. When the cursor moves, the popup closes. if `auto` is set, the popup 94 | will automatically display whenever the cursor moves inside a regular expression 95 | You can call `show` with your own display type to override your config 96 | 97 | ```lua 98 | require'regexplainer'.show { display = 'split' } 99 | ``` 100 | 101 | Or use the commands `RegexplainerShowSplit` or `RegexplainerShowPopup`. 102 | `RegexplainerHide` and `RegexplainerToggle` are also available. 103 | 104 | You can customize the popup window by specifying `options.popup.border`, 105 | which is a table of [popup options from nui][popup-options]. 106 | Any options specified for `options.popup` will also override the defaults. 107 | 108 | ```lua 109 | require'regexplainer'.show { 110 | display = 'popup', 111 | popup = { 112 | border = { 113 | padding = { 1, 2 }, 114 | style = 'solid', 115 | }, 116 | }, 117 | } 118 | ``` 119 | 120 | You could use this to, for example, set a different border based on the state of 121 | your editor. 122 | 123 | ### Render Options 124 | 125 | `narrative.indendation_string` can be a function taking the current component and 126 | returning an indendation indicator string. For example, to show the capture group on each line: 127 | 128 | ```lua 129 | narrative = { 130 | indentation_string = function(component) 131 | return component.capture_depth .. '> ' 132 | end 133 | }, 134 | ``` 135 | 136 | Input: 137 | 138 | ```javascript 139 | /zero(one(two(three)))/; 140 | ``` 141 | 142 | Output: 143 | 144 | ```markdown 145 | `zero` 146 | capture group 1: 147 | 1> `one` 148 | 1> capture group 2: 149 | 1> 2> `two` 150 | 1> 2> capture group 3: 151 | 1> 2> 3> `three` 152 | ``` 153 | 154 | ## Yank 155 | You can yank the regexplanation into any register with the `yank` function. The 156 | default register is `"`. This can be useful if you'd like to share the 157 | explanation of a regexp with your teammates, or if you'd like to report a 158 | mistake in regexplainer. The argument to `yank` is either a string (the register 159 | to yank to) or a table with `register: string` and options to `show` (e.g. `mode 160 | = 'narrative', narrative = {}`, etc.). 161 | 162 | For example, to copy the regexplanation to your system clipboard, use either of 163 | these: 164 | 165 | ```lua 166 | require'regexplainer'.yank'+' 167 | ``` 168 | 169 | ```lua 170 | require'regexplainer'.yank { register = '+' } 171 | ``` 172 | 173 | You can also use the command `RegexplainerYank` 174 | 175 | ```vim 176 | :RegexplainerYank + 177 | ``` 178 | 179 | ## 🗃️ TODO list 180 | - [ ] Display Regexp [railroad diagrams][railroad-diagrams] using ASCII-art 181 | - [ ] Display Regexp [railroad diagrams][railroad-diagrams] via 182 | [hologram][hologram] and [kitty image protocol][kitty], maybe with a sixel 183 | fallback 184 | - [ ] online documentation 185 | - [x] some unit tests or something, i guess 186 | 187 | 188 | [made-with-lua]: https://img.shields.io/badge/Made%20with%20Lua-blueviolet.svg?style=for-the-badge&logo=lua 189 | [build-status]: https://img.shields.io/github/actions/workflow/status/bennypowers/nvim-regexplainer/main.yml?branch=main&label=tests&style=for-the-badge 190 | [atom-regexp-railroad]: https://github.com/klorenz/atom-regex-railroad-diagrams/ 191 | [popup-options]: https://github.com/MunifTanjim/nui.nvim/tree/main/lua/nui/popup#border 192 | [railroad-diagrams]: https://github.com/tabatkins/railroad-diagrams/ 193 | [hologram]: https://github.com/edluffy/hologram.nvim 194 | [kitty]: https://sw.kovidgoyal.net/kitty/graphics-protocol/ 195 | [dotfyle]: https://dotfyle.com/plugins/bennypowers/nvim-regexplainer 196 | [dotfyle-badge]: https://dotfyle.com/plugins/bennypowers/nvim-regexplainer/shield?style=for-the-badge 197 | -------------------------------------------------------------------------------- /lua/regexplainer.lua: -------------------------------------------------------------------------------- 1 | local component = require 'regexplainer.component' 2 | local tree = require 'regexplainer.utils.treesitter' 3 | local utils = require 'regexplainer.utils' 4 | local Buffers = require 'regexplainer.buffers' 5 | 6 | local get_node_text = vim.treesitter.get_node_text 7 | local deep_extend = vim.tbl_deep_extend 8 | local map = vim.tbl_map 9 | local buf_delete = vim.api.nvim_buf_delete 10 | local ag = vim.api.nvim_create_augroup 11 | local au = vim.api.nvim_create_autocmd 12 | 13 | ---@class RegexplainerOptions 14 | ---@field mode? 'narrative'|'debug' # TODO: 'ascii', 'graphical' 15 | ---@field auto? boolean # Automatically display when cursor enters a regexp 16 | ---@field filetypes? string[] # Filetypes (extensions) to automatically show regexplainer. 17 | ---@field debug? boolean # Notify debug logs 18 | ---@field display? 'split'|'popup' 19 | ---@field mappings? RegexplainerMappings # keymappings to automatically bind. 20 | ---@field narrative? RegexplainerNarrativeRendererOptions # Options for the narrative renderer 21 | ---@field popup? NuiPopupBufferOptions # options for the popup buffer 22 | ---@field split? NuiSplitBufferOptions # options for the split buffer 23 | 24 | ---@class RegexplainerRenderOptions: RegexplainerOptions 25 | ---@field register "*"|"+"|'"'|":"|"."|"%"|"/"|"#"|"0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9" 26 | 27 | ---@class RegexplainerYankOptions: RegexplainerOptions 28 | ---@field register "*"|"+"|'"'|":"|"."|"%"|"/"|"#"|"0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9" 29 | 30 | ---@class RegexplainerMappings 31 | ---@field show? string # shows regexplainer 32 | ---@field hide? string # hides regexplainer 33 | ---@field toggle? string # toggles regexplainer 34 | ---@field yank? string # yanks regexplainer 35 | ---@field show_split? string # shows regexplainer in a split window 36 | ---@field show_popup? string # shows regexplainer in a popup window 37 | 38 | ---Maps config.mappings keys to vim command names and descriptions 39 | -- 40 | local config_command_map = { 41 | show = { 'RegexplainerShow', 'Show Regexplainer' }, 42 | hide = { 'RegexplainerHide', 'Hide Regexplainer' }, 43 | toggle = { 'RegexplainerToggle', 'Toggle Regexplainer' }, yank = { 'RegexplainerYank', 'Yank Regexplainer' }, 44 | show_split = { 'RegexplainerShowSplit', 'Show Regexplainer in a split Window' }, 45 | show_popup = { 'RegexplainerShowPopup', 'Show Regexplainer in a popup' }, 46 | } 47 | 48 | ---Augroup for auto = true 49 | local augroup_name = 'Regexplainer' 50 | 51 | ---@type RegexplainerOptions 52 | local default_config = { 53 | mode = 'narrative', 54 | auto = false, 55 | filetypes = { 56 | 'html', 57 | 'js', 'javascript', 'cjs', 'mjs', 58 | 'ts', 'typescript', 'cts', 'mts', 59 | 'tsx', 'typescriptreact', 'ctsx', 'mtsx', 60 | 'jsx', 'javascriptreact', 'cjsx', 'mjsx', 61 | }, 62 | debug = false, 63 | display = 'popup', 64 | mappings = { 65 | toggle = 'gR', 66 | }, 67 | narrative = { 68 | indentation_string = ' ', 69 | }, 70 | } 71 | 72 | --- A deep copy of the default config. 73 | --- During setup(), any user-provided config will be folded in 74 | ---@type RegexplainerOptions 75 | -- 76 | local local_config = deep_extend('keep', default_config, {}) 77 | 78 | --- Show the explainer for the regexp under the cursor 79 | ---@param options? RegexplainerOptions overrides for this call 80 | ---@return nil|number bufnr the bufnr of the regexplaination 81 | -- 82 | local function show_for_real(options) 83 | options = deep_extend('force', local_config, options or {}) 84 | local node, scratchnr, error = tree.get_regexp_pattern_at_cursor() 85 | 86 | if error and options.debug then 87 | utils.notify('Rexexplainer: ' .. error, 'debug') 88 | elseif node and scratchnr then 89 | ---@type boolean, RegexplainerRenderer 90 | local can_render, renderer = pcall(require, 'regexplainer.renderers.' .. options.mode) 91 | 92 | if not can_render then 93 | utils.notify(options.mode .. ' is not a valid renderer', 'warning') 94 | utils.notify(renderer, 'error') 95 | renderer = require 'regexplainer.renderers.narrative' 96 | end 97 | 98 | local components = component.make_components(scratchnr, node, nil, node) 99 | 100 | local buffer = Buffers.get_buffer(options) 101 | 102 | if not buffer and options.debug then 103 | renderer = require'regexplainer.renderers.debug' 104 | end 105 | 106 | local state = { full_regexp_text = get_node_text(node, scratchnr) } 107 | 108 | Buffers.render(buffer, renderer, components, options, state) 109 | buf_delete(scratchnr, { force = true }) 110 | else 111 | Buffers.hide_all() 112 | end 113 | end 114 | 115 | local disable_auto = false 116 | 117 | local M = {} 118 | 119 | --- Show the explainer for the regexp under the cursor 120 | ---@param options? RegexplainerOptions 121 | function M.show(options) 122 | disable_auto = true 123 | show_for_real(options) 124 | disable_auto = false 125 | end 126 | 127 | --- Yank the explainer for the regexp under the cursor into a given register 128 | ---@param options? string|RegexplainerYankOptions 129 | function M.yank(options) 130 | disable_auto = true 131 | if type(options) == 'string' then 132 | options = { register = options } 133 | end 134 | show_for_real(deep_extend('force', options, { display = 'register' })) 135 | disable_auto = false 136 | end 137 | 138 | --- Merge in the user config and setup key bindings 139 | ---@param config? RegexplainerOptions 140 | ---@return nil 141 | -- 142 | function M.setup(config) 143 | local_config = deep_extend('keep', config or {}, default_config) 144 | 145 | -- bind keys from config 146 | for cmdmap, binding in pairs(local_config.mappings) do 147 | local cmd, description = unpack(config_command_map[cmdmap]) 148 | local command = ':' .. cmd .. '' 149 | utils.map('n', binding, command, { desc = description }) 150 | end 151 | 152 | -- setup autocommand 153 | if local_config.auto then 154 | ag(augroup_name, { clear = true }) 155 | au('CursorMoved', { 156 | group = 'Regexplainer', 157 | pattern = map(function(x) return '*.' .. x end, local_config.filetypes), 158 | callback = function() 159 | if tree.has_regexp_at_cursor() and not disable_auto then 160 | show_for_real() 161 | end 162 | end, 163 | }) 164 | else 165 | pcall(vim.api.nvim_del_augroup_by_name, augroup_name) 166 | end 167 | end 168 | 169 | --- Hide any displayed regexplainer buffers 170 | -- 171 | function M.hide() 172 | Buffers.hide_all() 173 | end 174 | 175 | --- Toggle Regexplainer 176 | -- 177 | function M.toggle() 178 | if Buffers.is_open() then 179 | M.hide() 180 | else 181 | M.show() 182 | end 183 | end 184 | 185 | --- **INTERNAL** for testing only 186 | -- 187 | function M.teardown() 188 | local_config = vim.tbl_deep_extend('keep', {}, default_config) 189 | Buffers.clear_timers() 190 | pcall(vim.api.nvim_del_augroup_by_name, augroup_name) 191 | end 192 | 193 | --- **INTERNAL** notify the component tree for the current regexp 194 | -- 195 | function M.debug_components() 196 | ---@type any 197 | local mode = 'debug' 198 | show_for_real({ auto = false, display = 'split', mode = mode }) 199 | end 200 | 201 | return M 202 | -------------------------------------------------------------------------------- /lua/regexplainer/buffers/init.lua: -------------------------------------------------------------------------------- 1 | local utils = require 'regexplainer.utils' 2 | 3 | local get_current_win = vim.api.nvim_get_current_win 4 | local get_current_buf = vim.api.nvim_get_current_buf 5 | 6 | local all_buffers = {} 7 | 8 | ---@param object RegexplainerBuffer 9 | ---@returns 'NuiSplit'|'NuiPopup'|'Scratch' 10 | local function get_class_name(object) 11 | if object.type then 12 | return object.type 13 | else 14 | return getmetatable(getmetatable(object).__index).__name 15 | end 16 | end 17 | 18 | ---@param expected 'NuiSplit'|'NuiPopup'|'Scratch' 19 | ---@return fun(buffer:RegexplainerBuffer):boolean 20 | local function is_buftype(expected) 21 | return function(buffer) 22 | local passed, classname = pcall(get_class_name, buffer) 23 | return passed and classname == expected 24 | end 25 | end 26 | 27 | ---@alias Timer any 28 | 29 | ---@type Timer[] 30 | local timers = {} 31 | 32 | --- Closes all timers 33 | -- 34 | local function close_timers() 35 | for _, timer in ipairs(timers) do 36 | timer:close() 37 | end 38 | end 39 | 40 | ---@param options RegexplainerOptions 41 | ---@return RegexplainerBuffer 42 | local function get_buffer(options, state) 43 | if options.display == 'register' then 44 | return require'regexplainer.buffers.register'.get_buffer(options, state) 45 | elseif options.display == 'split' then 46 | return require'regexplainer.buffers.split'.get_buffer(options, state) 47 | else --if options.display == 'popup' then 48 | return require'regexplainer.buffers.popup'.get_buffer(options, state) 49 | end 50 | end 51 | 52 | -- Functions to create or modify the buffer which displays the regexplanation 53 | -- 54 | local M = {} 55 | 56 | --- Get the buffer in which to render the explainer 57 | ---@param options RegexplainerOptions 58 | ---@return RegexplainerBuffer 59 | -- 60 | function M.get_buffer(options) 61 | options = options or {} 62 | 63 | local state = { 64 | last = M.get_last_buffer() or {}, 65 | } 66 | 67 | local buffer = get_buffer(options, state) 68 | 69 | table.insert(all_buffers, buffer); 70 | 71 | state.last.parent = { 72 | winnr = get_current_win(), 73 | bufnr = get_current_buf(), 74 | } 75 | 76 | return buffer 77 | end 78 | 79 | ---@param buffer RegexplainerBuffer 80 | ---@param renderer RegexplainerRenderer 81 | ---@param options RegexplainerRenderOptions 82 | ---@param components RegexplainerComponent[] 83 | ---@param state RegexplainerRendererState 84 | -- 85 | function M.render(buffer, renderer, components, options, state) 86 | state.last = state.last or M.get_last_buffer() 87 | local lines = renderer.get_lines(components, options, state) 88 | buffer:init(lines, options, state) 89 | renderer.set_lines(buffer, lines) 90 | buffer:after(lines, options, state) 91 | end 92 | 93 | --- Close and unload a buffer 94 | ---@param buffer RegexplainerBuffer 95 | -- 96 | function M.kill_buffer(buffer) 97 | if buffer then 98 | buffer:hide() 99 | buffer:unmount() 100 | for i, buf in ipairs(all_buffers) do 101 | if buf == buffer then 102 | table.remove(all_buffers, i) 103 | end 104 | end 105 | end 106 | end 107 | 108 | --- Hide the last-opened Regexplainer buffer 109 | -- 110 | function M.hide_last() 111 | M.kill_buffer(M.get_last_buffer()) 112 | end 113 | 114 | --- Hide all known Regexplainer buffers 115 | -- 116 | function M.hide_all() 117 | for _, buffer in ipairs(all_buffers) do 118 | M.kill_buffer(buffer) 119 | end 120 | end 121 | 122 | --- Notify regarding all known Regexplainer buffers 123 | --- **INTERNAL**: for debug purposes only 124 | ---@private 125 | -- 126 | function M.debug_buffers() 127 | utils.notify(all_buffers) 128 | end 129 | 130 | --- get all active regexplaine buffers 131 | --- **INTERNAL**: for debug purposes only 132 | ---@private 133 | -- 134 | function M.get_all_buffers() 135 | return all_buffers 136 | end 137 | 138 | --- get last active regexplainer buffer 139 | --- **INTERNAL**: for debug purposes only 140 | ---@private 141 | -- 142 | function M.get_last_buffer() 143 | return all_buffers[#all_buffers] 144 | end 145 | 146 | --- Whether there are any open Regexplainer buffers 147 | ---@return boolean 148 | -- 149 | function M.is_open() 150 | return #all_buffers > 0 151 | end 152 | 153 | --- **INTERNAL** Register a debounce timer, 154 | --- so that we can close it to prevent memory leaks when closing buffers 155 | ---@param timer Timer 156 | -- 157 | function M.register_timer(timer) 158 | table.insert(timers, timer) 159 | end 160 | 161 | --- **INTERNAL** clear timers 162 | -- 163 | function M.clear_timers() 164 | pcall(close_timers) 165 | end 166 | 167 | ---Is it a popup buffer? 168 | ---@type fun(buffer:RegexplainerBuffer):boolean 169 | M.is_popup = is_buftype('NuiPopup') 170 | 171 | ---Is it a split buffer? 172 | ---@type fun(buffer:RegexplainerBuffer):boolean 173 | M.is_split = is_buftype('NuiSplit') 174 | 175 | ---Is it a scratch buffer? 176 | ---@type fun(buffer:RegexplainerBuffer):boolean 177 | M.is_scratch = is_buftype('Scratch') 178 | 179 | return M 180 | -------------------------------------------------------------------------------- /lua/regexplainer/buffers/popup.lua: -------------------------------------------------------------------------------- 1 | local Shared = require 'regexplainer.buffers.shared' 2 | 3 | local M = {} 4 | 5 | local au = vim.api.nvim_create_autocmd 6 | local get_win_width = vim.api.nvim_win_get_width 7 | local extend = vim.tbl_deep_extend 8 | 9 | ---@type NuiPopupBufferOptions 10 | local popup_defaults = { 11 | position = 2, 12 | relative = 'cursor', 13 | size = 1, 14 | border = { 15 | style = 'shadow', 16 | padding = { 1, 2 }, 17 | }, 18 | } 19 | 20 | local function init(self, lines, _, state) 21 | Shared.default_buffer_init(self) 22 | 23 | local win_width = get_win_width(state.last.parent.winnr) 24 | 25 | ---@type number|string 26 | local width = 0 27 | 28 | for _, line in ipairs(lines) do 29 | if #line > width then 30 | width = #line 31 | end 32 | end 33 | 34 | if (win_width * .75) < width then 35 | width = '75%' 36 | end 37 | 38 | self:set_size { width = width, height = #lines } 39 | end 40 | 41 | local function after(self, _, options, state) 42 | if options.auto then 43 | au({ 'BufLeave', 'BufWinLeave', 'CursorMoved' }, { 44 | buffer = state.last.parent.bufnr, 45 | once = true, 46 | callback = function() self:unmount() end, 47 | }) 48 | end 49 | end 50 | 51 | function M.get_buffer(options, state) 52 | local Popup = require'nui.popup' 53 | local buffer = Popup(extend('force', 54 | Shared.shared_options, 55 | popup_defaults, options.popup or {} 56 | ) or popup_defaults) 57 | buffer.type = 'NuiPopup' 58 | state.last = buffer 59 | buffer.init = init 60 | buffer.after = after 61 | return buffer 62 | end 63 | 64 | return M 65 | -------------------------------------------------------------------------------- /lua/regexplainer/buffers/register.lua: -------------------------------------------------------------------------------- 1 | local Shared = require'regexplainer.buffers.shared' 2 | local Buffers = require'regexplainer.buffers' 3 | 4 | local M = {} 5 | 6 | ---A Nui-compatible scratch buffer. 7 | ---ephemeral, invisible, unlisted 8 | --- 9 | ---@class ScratchBuffer 10 | ---@field _ NuiBufferOptions 11 | ---@field bufnr number 12 | local Scratch = setmetatable({ 13 | super = nil 14 | }, { 15 | __name = 'Scratch', 16 | __call = function(class, options) 17 | local self = setmetatable({}, { __index = class }) 18 | self._ = { 19 | buf_options = options.buf_options, 20 | loading = false, 21 | mounted = false, 22 | win_enter = options.enter, 23 | win_options = options.win_options, 24 | } 25 | self.bufnr = vim.api.nvim_create_buf(false, true) 26 | self.type = 'Scratch' 27 | return self 28 | end 29 | }) 30 | 31 | ---Adhere to the NUI buffer interface by setting the `mounted` flag 32 | function Scratch:mount() 33 | self._.mounted = true; 34 | end 35 | 36 | ---Delete the buffer and unset the `mounted` flag 37 | function Scratch:unmount() 38 | vim.api.nvim_buf_delete(self.bufnr, { force = true }) 39 | self._.mounted = false; 40 | end 41 | 42 | function Scratch:hide() end 43 | 44 | local function get_buffer_contents(bufnr) 45 | local content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 46 | return table.concat(content, '\n') 47 | end 48 | 49 | ---Yank the contents of the buffer, optionally into a specified register 50 | ---@param bufnr number The buffer to yank 51 | ---@param register? string The register to yank to. defaults to '*' 52 | local function yank(bufnr, register) 53 | register = register or '*' 54 | vim.api.nvim_buf_call(bufnr, function() 55 | vim.fn.setreg(register, get_buffer_contents(bufnr), 'l') 56 | end) 57 | end 58 | 59 | local function after(self, _, options, _) 60 | yank(self.bufnr, options.register or '"') 61 | Buffers.kill_buffer(self) 62 | end 63 | 64 | --- Create scratch buffer 65 | function M.get_buffer(_, _) 66 | local buffer = Scratch({}) 67 | buffer.type = 'Scratch' 68 | buffer.init = Shared.default_buffer_init 69 | buffer.after = after 70 | return buffer 71 | end 72 | 73 | return M 74 | 75 | -------------------------------------------------------------------------------- /lua/regexplainer/buffers/shared.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---@class RegexplainerBuffer: ScratchBuffer|NuiPopup|NuiSplit 4 | ---@field type "NuiPopup"|"NuiSplit"|"Scratch" 5 | ---@field init fun(buf:RegexplainerBuffer,lines:string[],options:RegexplainerOptions,state: RegexplainerRendererState):nil 6 | ---@field after fun(buf:RegexplainerBuffer,lines:string[],options:RegexplainerOptions,state: RegexplainerRendererState):nil 7 | ---@field winid number 8 | ---@field hide fun():nil 9 | 10 | ---@class WindowOptions 11 | ---@field wrap boolean 12 | ---@field conceallevel 0|1|2|3 13 | 14 | ---@class BufferOptions 15 | ---@field filetype string 16 | ---@field readonly boolean 17 | ---@field modifiable boolean 18 | 19 | ---@class NuiSplitBufferOptions: NuiBufferOptions 20 | ---@field relative 'editor'|'window' 21 | ---@field position 'bottom'|'top' 22 | ---@field size string 23 | 24 | ---@class NuiBorderOptions 25 | ---@field padding number[] 26 | ---@field style 'shadow'|'double' 27 | 28 | ---@class NuiPopupBufferOptions: NuiBufferOptions 29 | ---@field relative 'cursor' 30 | ---@field position number 31 | ---@field size number|table<'width'|'height', number> 32 | ---@field border NuiBorderOptions 33 | 34 | ---@alias RegexplainerBufferOptions NuiSplitBufferOptions|NuiPopupBufferOptions 35 | 36 | ---@class NuiBufferOptions 37 | ---@field enter boolean 38 | ---@field focusable boolean 39 | ---@field buf_options BufferOptions 40 | ---@field win_options WindowOptions 41 | -- 42 | M.shared_options = { 43 | enter = false, 44 | focusable = false, 45 | buf_options = { 46 | filetype = 'Regexplainer', 47 | readonly = false, 48 | modifiable = true, 49 | }, 50 | win_options = { 51 | wrap = true, 52 | conceallevel = 2, 53 | }, 54 | } 55 | 56 | function M.default_buffer_after(self, _, options, _, state) 57 | if options.auto then 58 | vim.api.nvim_create_autocmd({ 'BufHidden', 'BufLeave' }, { 59 | group = vim.api.nvim_create_augroup('regexplainer_buf_after', { clear = true }), 60 | buffer = state.last.parent.bufnr, 61 | callback = function() 62 | M.kill_buffer(self) 63 | end 64 | }) 65 | end 66 | end 67 | 68 | function M.default_buffer_init(self, _, _, _) 69 | if not self._.mounted then self:mount() end 70 | end 71 | 72 | return M 73 | -------------------------------------------------------------------------------- /lua/regexplainer/buffers/split.lua: -------------------------------------------------------------------------------- 1 | local Shared = require 'regexplainer.buffers.shared' 2 | local Split = require 'nui.split' 3 | 4 | local set_current_win = vim.api.nvim_set_current_win 5 | local set_current_buf = vim.api.nvim_set_current_buf 6 | local win_set_height = vim.api.nvim_win_set_height 7 | local extend = vim.tbl_deep_extend 8 | 9 | local M = {} 10 | 11 | ---@type NuiSplitBufferOptions 12 | local split_defaults = { 13 | relative = 'editor', 14 | position = 'bottom', 15 | size = '20%', 16 | } 17 | 18 | local function after(self, lines, _, state) 19 | set_current_win(state.last.parent.winnr) 20 | set_current_buf(state.last.parent.bufnr) 21 | win_set_height(self.winid, #lines) 22 | end 23 | 24 | function M.get_buffer(options, state) 25 | if state.last.type == 'NuiSplit' then 26 | return state.last 27 | end 28 | local buffer = Split(extend('force', Shared.shared_options, split_defaults, options.split or {})) 29 | buffer.type = 'NuiSplit' 30 | state.last = buffer 31 | buffer.init = Shared.default_buffer_init 32 | buffer.after = after 33 | return buffer 34 | end 35 | 36 | return M 37 | -------------------------------------------------------------------------------- /lua/regexplainer/component/descriptions.lua: -------------------------------------------------------------------------------- 1 | local utils = require 'regexplainer.utils' 2 | local Predicates = require 'regexplainer.component.predicates' 3 | local get_node_text = vim.treesitter.get_node_text 4 | 5 | local M = {} 6 | 7 | --- Given a quantifier, return a pretty description like "2 or more times" 8 | ---@param quantifier_node TreesitterNode 9 | ---@return string 10 | -- 11 | function M.describe_quantifier(quantifier_node, bufnr) 12 | -- TODO: there's probably a better way to do this 13 | local text = get_node_text(quantifier_node, bufnr) 14 | if text:match ',' then 15 | local matches = {} 16 | for match in text:gmatch '%d+' do 17 | table.insert(matches, match) 18 | end 19 | local min, max = unpack(matches) 20 | if max then 21 | return min .. '-' .. max .. 'x' 22 | else 23 | return '>= ' .. min .. 'x' 24 | end 25 | else 26 | local match = text:match '%d+' 27 | return match .. 'x' 28 | end 29 | end 30 | 31 | --- Given `[A-Z0-9._%+_]`, return `'A-Z, 0-9, ., _, %, +, or -'` 32 | ---@param component RegexplainerComponent 33 | ---@return string 34 | -- 35 | function M.describe_character_class(component) 36 | local description = (component.negative and 'Any except ' or 'One of ') 37 | for i, child in ipairs(component.children) do 38 | -- TODO: lua equivalent of Intl? 39 | local oxford = (#(component.children) > 1 and i == #(component.children)) and 'or ' or '' 40 | local initial_sep = i == 1 and '' or ', ' 41 | local text = utils.escape_markdown(child.text) 42 | 43 | if Predicates.is_identity_escape(child) then 44 | text = '`' .. text:sub(-1) .. '`' 45 | elseif Predicates.is_escape(child) then 46 | text = '**' .. M.describe_escape(text) .. '**' 47 | else 48 | text = '`' .. text .. '`' 49 | end 50 | 51 | description = description .. initial_sep .. oxford .. text 52 | end 53 | return description 54 | end 55 | 56 | ---@param escape string 57 | ---@return string 58 | function M.describe_escape(escape) 59 | local char = escape:gsub([[\\]], [[\]]):sub(2) 60 | if char == 'd' then return '0-9' 61 | elseif char == 'n' then return 'LF' 62 | elseif char == 'r' then return 'CR' 63 | elseif char == 's' then return 'WS' 64 | elseif char == 'b' then return 'WB' 65 | elseif char == 't' then return 'TAB' 66 | elseif char == 'w' then return 'WORD' 67 | else return char 68 | end 69 | end 70 | 71 | ---@param component RegexplainerComponent 72 | ---@return string 73 | function M.describe_character(component) 74 | local type = component.type 75 | if type == 'start_assertion' then return 'START' 76 | elseif type == 'end_assertion' then return 'END' 77 | elseif type == 'any_character' then return 'ANY' 78 | else return component.text 79 | end 80 | end 81 | 82 | return M 83 | -------------------------------------------------------------------------------- /lua/regexplainer/component/init.lua: -------------------------------------------------------------------------------- 1 | local node_pred = require 'regexplainer.utils.treesitter' 2 | local Predicates = require'regexplainer.component.predicates' 3 | local Utils = require 'regexplainer.utils' 4 | 5 | ---@diagnostic disable-next-line: unused-local 6 | local log = require 'regexplainer.utils'.debug 7 | 8 | local get_node_text = vim.treesitter.get_node_text 9 | local extend = vim.tbl_extend 10 | local deep_extend = vim.tbl_deep_extend 11 | 12 | ---@class RegexplainerBaseComponent 13 | ---@field type RegexplainerComponentType # Which type of component 14 | ---@field text string # full text of this regexp component 15 | ---@field capture_depth number # how many levels deep is this component, where 0 is top-level. 16 | ---@field quantifier? string # a quantified regexp component 17 | ---@field optional? boolean # a regexp component marked with `?` 18 | ---@field zero_or_more? boolean # a regexp component marked with `*` 19 | ---@field one_or_more? boolean # a regexp component marked with `+` 20 | ---@field lazy? boolean # a regexp quantifier component marked with `?` 21 | ---@field negative? boolean # when it's a negative lookaround 22 | ---@field direction? 'ahead'|'behind' # when it's a lookaround, is it a lookahead or a lookbehind 23 | ---@field error? any # parsing error 24 | 25 | ---@class RegexplainerParentComponent : RegexplainerBaseComponent 26 | ---@field children? (RegexplainerComponent)[] # Components may contain other components, e.g. capture groups 27 | 28 | ---@class RegexplainerCaptureGroupComponent : RegexplainerParentComponent 29 | ---@field group_name? string # the name of the capture group, if it's a named group 30 | ---@field capture_group? number # which capture group does this group represent? 31 | 32 | ---@alias RegexplainerComponentType 33 | ---| 'alternation' 34 | ---| 'start_assertion' 35 | ---| 'boundary_assertion' 36 | ---| 'character_class' 37 | ---| 'character_class_escape' 38 | ---| 'class_range' 39 | ---| 'control_escape', 40 | ---| 'decimal_escape', 41 | ---| 'identity_escape', 42 | ---| 'lookaround_assertion' 43 | ---| 'pattern' 44 | ---| 'pattern_character' 45 | ---| 'term' 46 | ---| 'root' 47 | 48 | ---@alias RegexplainerComponent 49 | ---| RegexplainerBaseComponent 50 | ---| RegexplainerCaptureGroupComponent 51 | 52 | local M = {} 53 | 54 | -- keep track of how many captures we've seen 55 | -- make sure to unset when finished an entire regexp 56 | -- 57 | local capture_tally = 0 58 | 59 | 60 | ---@param node TreesitterNode 61 | local function has_lazy(node) 62 | for child in node:iter_children() do 63 | if child:type() == 'lazy' then 64 | return true 65 | end 66 | end 67 | return false 68 | end 69 | 70 | ---@alias TreesitterNode any 71 | 72 | --- Transform a treesitter node to a table of components which are easily rendered 73 | ---@param bufnr number 74 | ---@param node TSNode 75 | ---@param parent? RegexplainerComponent 76 | ---@param root_regex_node TSNode 77 | ---@return RegexplainerComponent[] 78 | -- 79 | function M.make_components(bufnr, node, parent, root_regex_node) 80 | local text = get_node_text(node, bufnr) 81 | local cached = Utils.get_cached(text) 82 | local parent_depth = parent and parent.capture_depth or 0 83 | 84 | if cached then return cached end 85 | 86 | local components = {} 87 | 88 | local node_type = node:type() 89 | 90 | ---@return RegexplainerComponent component 91 | local function c(component) 92 | return extend('force', { 93 | type = 'root', 94 | capture_depth = parent_depth, 95 | }, component) 96 | end 97 | 98 | if node_type == 'alternation' and node == root_regex_node then 99 | table.insert(components, c { 100 | type = node_type, 101 | text = text, 102 | children = {}, 103 | }) 104 | end 105 | 106 | for child in node:iter_children() do 107 | local type = child:type() 108 | 109 | local child_text = get_node_text(child, bufnr) 110 | 111 | local previous = components[#components] 112 | 113 | local function append_previous(props) 114 | if previous.type == 'identity_escape' then 115 | previous.text = previous.text:gsub([[^\+]], '') 116 | end 117 | 118 | if Predicates.is_simple_pattern_character(previous) and #previous.text > 1 then 119 | local last_char = previous.text:sub(-1) 120 | if Predicates.is_identity_escape(previous) 121 | and Predicates.is_simple_component(previous) then 122 | previous.text = previous.text .. last_char 123 | elseif not Predicates.is_control_escape(previous) 124 | and not Predicates.is_character_class_escape(previous) then 125 | previous.text = previous.text:sub(1, -2) 126 | table.insert(components, c { 127 | type = 'pattern_character', 128 | text = last_char, 129 | }) 130 | previous = components[#components] 131 | end 132 | end 133 | 134 | components[#components] = deep_extend('force', previous, props) 135 | 136 | return components[#components] 137 | end 138 | 139 | -- the following node types should not be added to the component tree 140 | -- instead, they should merely modify the previous node in the tree 141 | if type == 'optional' or type == 'one_or_more' or type == 'zero_or_more' then 142 | append_previous { 143 | [type] = true, 144 | lazy = has_lazy(child), 145 | } 146 | elseif type == 'count_quantifier' then 147 | append_previous { 148 | quantifier = require 'regexplainer.component.descriptions'.describe_quantifier(child, bufnr), 149 | lazy = has_lazy(child), 150 | } 151 | 152 | 153 | -- pattern characters and simple escapes can be collapsed together 154 | -- so long as they are not immediately followed by a modifier 155 | elseif type == 'pattern_character' 156 | and Predicates.is_simple_pattern_character(previous) then 157 | if previous.type == 'identity_escape' then 158 | previous.text = previous.text:gsub([[^\+]], '') 159 | end 160 | 161 | previous.text = previous.text .. child_text 162 | previous.type = 'pattern_character' 163 | elseif (type == 'identity_escape' or type == 'decimal_escape') 164 | and Predicates.is_simple_pattern_character(previous) then 165 | if node_type ~= 'character_class' 166 | and not node_pred.is_modifier(child:next_sibling()) then 167 | previous.text = previous.text .. child_text:gsub([[^\+]], '') 168 | else 169 | table.insert(components, c { 170 | type = type, 171 | text = child_text 172 | }) 173 | end 174 | 175 | elseif type == 'start_assertion' then 176 | table.insert(components, c { type = type, text = '^' }) 177 | 178 | -- handle errors 179 | -- 180 | elseif type == 'ERROR' then 181 | local error_text = get_node_text(child, bufnr) 182 | local row, e_start, _, e_end = child:range() 183 | local _, re_start = node:range() 184 | table.insert(components, c { 185 | type = type, 186 | text = get_node_text(child, bufnr), 187 | error = { 188 | text = error_text, 189 | position = { row, { e_start, e_end } }, 190 | start_offset = re_start, 191 | }, 192 | }) 193 | -- all other node types should be added to the tree 194 | else 195 | 196 | ---@type RegexplainerComponent 197 | local component = c { 198 | type = type, 199 | text = child_text, 200 | } 201 | 202 | -- increment `depth` for each layer of capturing groups encountered 203 | if type == 'pattern' or type == 'term' then 204 | component.capture_depth = parent_depth or 0 205 | elseif type:find [[capturing_group$]] then 206 | component.capture_depth = component.capture_depth + 1 207 | end 208 | 209 | -- negated character class 210 | if type == 'character_class' and component.text:find [[^%[%^]] then 211 | component.negative = true 212 | component.children = M.make_components(bufnr, child, nil, root_regex_node) 213 | table.insert(components, component) 214 | 215 | -- alternations are containers which do not increase depth 216 | elseif type == 'alternation' then 217 | component.children = M.make_components(bufnr, child, nil, root_regex_node) 218 | table.insert(components, component) 219 | 220 | -- skip group_name and punctuation nodes 221 | elseif type ~= 'group_name' 222 | and not node_pred.is_punctuation(type) then 223 | if node_pred.is_container(child) then 224 | 225 | -- increment the capture group tally 226 | if type == 'named_capturing_group' or type == 'anonymous_capturing_group' then 227 | capture_tally = capture_tally + 1 228 | component.capture_group = capture_tally 229 | end 230 | 231 | if node_pred.is_named_capturing_group(child) then 232 | -- find the group_name and apply it to the component 233 | for grandchild in child:iter_children() do 234 | if node_pred.is_group_name(grandchild) then 235 | component.group_name = get_node_text(grandchild, bufnr) 236 | break 237 | end 238 | end 239 | end 240 | 241 | if node_pred.is_lookaround_assertion(child) then 242 | local _, _, behind, sign = string.find(text, '%(%?( 10 | local M = {} 11 | 12 | M.narrative = require 'regexplainer.renderers.narrative' 13 | 14 | return M 15 | -------------------------------------------------------------------------------- /lua/regexplainer/renderers/narrative/init.lua: -------------------------------------------------------------------------------- 1 | local narrative = require 'regexplainer.renderers.narrative.narrative' 2 | local buffers = require 'regexplainer.buffers' 3 | 4 | -- A textual, narrative renderer which describes a regexp in terse prose 5 | -- 6 | local M = {} 7 | 8 | ---@param components RegexplainerComponent[] 9 | ---@param options RegexplainerOptions 10 | ---@param state RegexplainerNarrativeRendererState 11 | function M.get_lines(components, options, state) 12 | local lines = narrative.recurse(components, options, state) 13 | return lines 14 | end 15 | 16 | ---@param buffer RegexplainerBuffer 17 | ---@param lines string[] 18 | ---@return string[] 19 | function M.set_lines(buffer, lines) 20 | if buffers.is_scratch(buffer) then 21 | vim.api.nvim_buf_set_lines(buffer.bufnr, 0, #lines, false, lines) 22 | elseif buffer.winid then 23 | vim.api.nvim_win_call(buffer.winid, function() 24 | vim.lsp.util.stylize_markdown(buffer.bufnr, lines, {}) 25 | end) 26 | end 27 | return lines 28 | end 29 | 30 | return M 31 | -------------------------------------------------------------------------------- /lua/regexplainer/renderers/narrative/narrative.lua: -------------------------------------------------------------------------------- 1 | local D = require'regexplainer.component.descriptions' 2 | local P = require'regexplainer.component.predicates' 3 | local U = require'regexplainer.utils' 4 | 5 | local extend = function(a, b) return vim.tbl_extend('force', a, b) end 6 | 7 | local M = {} 8 | 9 | ---@class RegexplainerNarrativeRendererOptions 10 | ---@field indentation_string string|fun(component:RegexplainerComponent):string # clause separator 11 | 12 | ---@class RegexplainerNarrativeRendererState : RegexplainerRendererState 13 | ---@field first boolean # is it first in the term? 14 | ---@field last boolean # is it last in the term? 15 | ---@field parent RegexplainerComponent the parent component 16 | 17 | --- Get a description of the component's quantifier, optionality, etc 18 | ---@param component RegexplainerComponent 19 | ---@return string 20 | -- 21 | local function get_quantifier(component) 22 | local quant = '' 23 | 24 | if component.quantifier then 25 | quant = ' (_' .. component.quantifier .. '_)' 26 | end 27 | 28 | if component.optional then 29 | quant = quant .. ' (_optional_)' 30 | elseif component.zero_or_more then 31 | quant = quant .. ' (_>= 0x_)' 32 | elseif component.one_or_more then 33 | quant = quant .. ' (_>= 1x_)' 34 | end 35 | 36 | if component.lazy then 37 | quant = quant .. ' (_lazy_)' 38 | end 39 | 40 | return quant 41 | end 42 | 43 | ---@param component RegexplainerComponent # component to render 44 | ---@param options RegexplainerOptions # the original configured separator string 45 | ---@return string # the next separator string 46 | local function get_indent_string(component, options) 47 | local indent = options.narrative.indentation_string 48 | if component.type == 'pattern' or component.type == 'term' then 49 | return '' 50 | elseif type(indent) == "function" then 51 | return indent(component) 52 | else 53 | return indent 54 | end 55 | end 56 | 57 | --- Get a description of the component's quantifier, optionality, etc 58 | ---@param component RegexplainerComponent 59 | ---@param options RegexplainerOptions 60 | ---@param state RegexplainerNarrativeRendererState 61 | ---@return string 62 | -- 63 | local function get_prefix(component, options, state) 64 | local prefix = '' 65 | 66 | if state.first and not state.last then 67 | prefix = '' 68 | elseif state.last and not state.first then 69 | prefix = '' 70 | end 71 | 72 | if P.is_alternation(component) then 73 | prefix = 'Either ' 74 | end 75 | 76 | if component.optional or component.quantifier then 77 | prefix = prefix .. '\n' 78 | end 79 | 80 | return prefix 81 | end 82 | 83 | --- Get a suffix for the current clause 84 | ---@param component RegexplainerComponent 85 | ---@param options RegexplainerOptions 86 | ---@param state RegexplainerNarrativeRendererState 87 | ---@return string 88 | -- 89 | local function get_suffix(component, options, state) 90 | local suffix = '' 91 | if not P.is_capture_group(component) and not P.is_lookaround_assertion(component) then 92 | suffix = get_quantifier(component) 93 | end 94 | if component.zero_or_more or component.quantifier or component.one_or_more then 95 | suffix = suffix .. '\n' 96 | end 97 | return suffix 98 | end 99 | 100 | ---@param component RegexplainerComponent 101 | ---@param options RegexplainerOptions 102 | ---@param state RegexplainerNarrativeRendererState 103 | ---@return string 104 | -- 105 | local function get_infix(component, options, state) 106 | if P.is_term(component) or P.is_pattern(component) then 107 | if P.is_only_chars(component) then 108 | return '`' .. component.text .. '`' 109 | else 110 | local sep = get_indent_string(component, options) 111 | local line_sep = P.is_alternation(state.parent) and '' or '\n' 112 | local sublines = M.recurse(component.children, options, state) 113 | local contents = table.concat(sublines, '\n') 114 | return '' 115 | .. get_quantifier(component) 116 | .. line_sep 117 | .. string.rep(sep, component.capture_depth) 118 | .. line_sep 119 | .. contents 120 | .. line_sep 121 | end 122 | end 123 | 124 | if P.is_alternation(component) then 125 | -- we have to do alternations by iterating instead of recursing 126 | local infix = '' 127 | for i, child in ipairs(component.children) do 128 | local oxford = i == #component.children and 'or ' or '' 129 | local first_in_alt = i == 1 130 | local last_in_alt = i == #component.children 131 | local next_state = extend(state, { 132 | first = first_in_alt, 133 | last = last_in_alt, 134 | parent = component 135 | }) 136 | infix = infix 137 | .. (first_in_alt and '' or #component.children == 2 and ' ' or ', ') 138 | .. oxford 139 | .. get_prefix(child, options, next_state) 140 | .. get_infix(child, options, next_state) 141 | .. get_suffix(child, options, next_state) 142 | end 143 | return infix 144 | end 145 | 146 | if P.is_capture_group(component) then 147 | local indent = get_indent_string(component, options) 148 | local sublines = M.recurse(component.children, options, extend(state, { 149 | parent = component 150 | })) 151 | local contents = table.concat(sublines, '\n' .. indent) 152 | local name = component.group_name and ('`' .. component.group_name .. '`') or '' 153 | local header = '' 154 | if component.type == 'named_capturing_group' then 155 | header = 'named capture group ' .. component.capture_group .. ' ' .. name 156 | elseif component.type == 'non_capturing_group' then 157 | header = 'non-capturing group ' 158 | else 159 | header = 'capture group ' .. component.capture_group 160 | end 161 | header = header:gsub(' $', '') 162 | return '' 163 | .. header 164 | .. get_quantifier(component) 165 | .. ':\n' 166 | .. indent 167 | .. contents 168 | end 169 | 170 | if P.is_character_class(component) then 171 | return '\n' .. D.describe_character_class(component) 172 | end 173 | 174 | if P.is_escape(component) then 175 | if P.is_identity_escape(component) then 176 | local text = component.text:sub(2) 177 | if text == '' then text = component.text end 178 | local escaped_text = U.escape_markdown(text) 179 | if escaped_text == ' ' then escaped_text = '(space)' end 180 | return '`' .. escaped_text .. '`' 181 | elseif P.is_decimal_escape(component) then 182 | return '`' .. D.describe_escape(component.text) .. '`' 183 | else 184 | return '**' .. D.describe_escape(component.text) .. '**' 185 | end 186 | end 187 | 188 | if P.is_character_class_escape(component) then 189 | return '**' .. D.describe_escape(component.text) .. '**' 190 | end 191 | 192 | if P.is_pattern_character(component) then 193 | return '`' .. U.escape_markdown(component.text) .. '`' 194 | end 195 | 196 | if P.is_lookaround_assertion(component) then 197 | local indent = get_indent_string(component, options) 198 | local sublines = M.recurse(component.children, options, extend(state, { 199 | parent = component 200 | })) 201 | local contents = table.concat(sublines, '\n'..indent) 202 | 203 | local negation = (component.negative and 'NOT ' or '') 204 | local direction = 'followed by' 205 | if component.direction == 'behind' then 206 | direction = 'preceeding' 207 | end 208 | 209 | return '' 210 | .. '**' 211 | .. negation 212 | .. direction 213 | .. '**' 214 | .. get_quantifier(component) 215 | .. ':\n' 216 | .. string.rep(indent, component.capture_depth) 217 | .. contents 218 | .. '\n' 219 | end 220 | 221 | if P.is_special_character(component) then 222 | local infix = '' 223 | infix = infix .. '**' .. D.describe_character(component) .. '**' 224 | if P.is_start_assertion(component) then 225 | infix = infix .. '\n' 226 | end 227 | return infix 228 | end 229 | 230 | if P.is_boundary_assertion(component) then 231 | return '**WB**' 232 | end 233 | end 234 | 235 | ---@param component RegexplainerComponent 236 | ---@return nil|RegexplainerComponent error 237 | local function find_error(component) 238 | local error 239 | if component.type == 'ERROR' then 240 | error = component 241 | elseif component.children then 242 | for _, child in ipairs(component.children) do 243 | error = find_error(child) 244 | if error then return error end 245 | end 246 | end 247 | return error 248 | end 249 | 250 | local function get_error_message(error, state) 251 | local lines = {} 252 | lines[1] = '🚨 **Regexp contains an ERROR** at' 253 | lines[2] = '`' .. state.full_regexp_text .. '`' 254 | lines[3] = ' ' 255 | local error_start_col = error.error.position[2][1] 256 | local from_re_start_to_err_start = error_start_col - error.error.start_offset + 1 257 | for _ = 1, from_re_start_to_err_start do 258 | lines[3] = lines[3] .. ' ' 259 | end 260 | lines[3] = lines[3] .. '^' 261 | return lines 262 | end 263 | 264 | local function is_non_empty(line) 265 | return line ~= '' 266 | end 267 | 268 | local function split_lines(clause) 269 | return vim.split(clause, '\n') 270 | end 271 | 272 | local function trim_end(str) 273 | local s = str:gsub(' +$', '') 274 | return s 275 | end 276 | 277 | ---@param components (RegexplainerComponent)[] 278 | ---@param options RegexplainerOptions 279 | ---@param state RegexplainerNarrativeRendererState 280 | ---@return string[] lines, RegexplainerNarrativeRendererState state 281 | function M.recurse(components, options, state) 282 | local clauses = {} 283 | for i, component in ipairs(components) do 284 | local first = i == 1 285 | local last = i == #components 286 | local error = find_error(component) 287 | 288 | if error then 289 | return get_error_message(error, state), state 290 | end 291 | 292 | local next_state = extend(state, { 293 | first = first, 294 | last = last, 295 | parent = state.parent or { type = 'root' } 296 | }) 297 | 298 | local clause = '' 299 | .. get_prefix(component, options, next_state) 300 | .. get_infix(component, options, next_state) 301 | .. get_suffix(component, options, next_state) 302 | 303 | table.insert(clauses, clause) 304 | end 305 | 306 | local lines = vim.iter(clauses) 307 | :map(split_lines) 308 | :flatten() 309 | :filter(is_non_empty) 310 | :map(trim_end) 311 | :totable() 312 | 313 | return lines, state 314 | end 315 | 316 | return M 317 | -------------------------------------------------------------------------------- /lua/regexplainer/utils/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.map(mode, lhs, rhs, opts) 4 | local options = { 5 | noremap = true, 6 | silent = true 7 | } 8 | if opts then 9 | options = vim.tbl_extend("force", options, opts) 10 | end 11 | local stat, error = pcall(vim.api.nvim_set_keymap, mode, lhs, rhs, options) 12 | if not stat then 13 | vim.notify(error, vim.log.levels.ERROR) 14 | end 15 | end 16 | 17 | -- Composes vim.inspect witht vim.notify 18 | -- 19 | function M.notify(value, level, options) 20 | return vim.notify(vim.inspect(value), level, options) 21 | end 22 | 23 | -- For debugging 24 | function M.debug(...) 25 | vim.notify(table.concat(vim.tbl_map(vim.inspect, { ... }), '\n')) 26 | end 27 | 28 | -- Escape markdown syntax in a given string 29 | -- 30 | function M.escape_markdown(str) 31 | -- return str 32 | -- :gsub('_', [[\_]]) 33 | -- :gsub('\\', [[\\]]) 34 | -- :gsub('*', [[\*]]) 35 | -- :gsub('`', [[\`]]) 36 | -- :gsub('>', [[\>]]) 37 | -- :gsub('<', [[\<]]) 38 | return string.gsub(str, [==[([\_*`><])]==], [[\%1]]) 39 | end 40 | 41 | local lookuptables = {} 42 | 43 | setmetatable(lookuptables, { __mode = "v" }) -- make values weak 44 | 45 | local function get_lookup(xs) 46 | local key = type(xs) == 'string' and xs or table.concat(xs, '-') 47 | if lookuptables[key] then return lookuptables[key] 48 | else 49 | local lookup = {} 50 | for _, v in ipairs(xs) do lookup[v] = true end 51 | lookuptables[key] = lookup 52 | return lookup 53 | end 54 | end 55 | 56 | --- Memoized `elem` predicate 57 | ---@generic T 58 | ---@param x T needle 59 | ---@param xs T[] haystack 60 | -- 61 | function M.elem(x, xs) 62 | return get_lookup(xs)[x] or false 63 | end 64 | 65 | function M.get_cached(key) 66 | return lookuptables[key] 67 | end 68 | 69 | function M.set_cached(key, table) 70 | lookuptables[key] = table 71 | end 72 | 73 | return M 74 | -------------------------------------------------------------------------------- /lua/regexplainer/utils/treesitter.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local get_query = vim.treesitter.query.get 4 | local get_parser = vim.treesitter.get_parser 5 | local get_node_text = vim.treesitter.get_node_text 6 | local get_node = vim.treesitter.get_node 7 | local get_captures_at_cursor = vim.treesitter.get_captures_at_cursor 8 | local is_in_node_range = vim.treesitter.is_in_node_range 9 | 10 | local node_types = { 11 | 'alternation', 12 | 'boundary_assertion', 13 | 'character_class', 14 | 'character_class_escape', 15 | 'chunk', 16 | 'class_range', 17 | 'count_quantifier', 18 | 'document', 19 | 'group_name', 20 | 'identity_escape', 21 | 'lookaround_assertion', 22 | 'named_capturing_group', 23 | 'non_capturing_group', 24 | 'one_or_more', 25 | 'optional', 26 | 'pattern', 27 | 'pattern_character', 28 | 'program', 29 | 'source', 30 | 'term', 31 | 'zero_or_more', 32 | } 33 | 34 | for _, type in ipairs(node_types) do 35 | ---@type fun(node: TSNode): boolean 36 | M['is_' .. type] = function(node) 37 | if not node then 38 | return false 39 | end 40 | return node and node:type() == type 41 | end 42 | end 43 | 44 | ---@param original_node TSNode regex_pattern node 45 | ---@return TSNode|nil, integer|nil, string|nil 46 | local function get_pattern(original_node) 47 | local buf = vim.api.nvim_create_buf(false, false) 48 | vim.api.nvim_buf_set_lines(buf, 0, 1,true, { get_node_text(original_node, 0) }) 49 | local node 50 | for _, tree in ipairs(get_parser(buf, 'regex'):parse()) do 51 | node = tree:root() 52 | while node and node:type() ~= 'pattern' do 53 | node = node:child(0) 54 | end 55 | end 56 | if node and node:type() == 'pattern' then 57 | return node, buf, nil 58 | end 59 | return nil, buf, 'could not find pattern node' 60 | end 61 | 62 | function M.has_regexp_at_cursor() 63 | for _, cap in ipairs(get_captures_at_cursor(0)) do 64 | if cap == 'string.regexp' then 65 | return true 66 | end 67 | end 68 | return false 69 | end 70 | 71 | ---Containers are regexp treesitter nodes which may contain leaf nodes like pattern_character. 72 | ---An example container is anonymous_capturing_group. 73 | -- 74 | ---@param node TSNode regexp treesitter node 75 | ---@return boolean 76 | function M.is_container(node) 77 | if node:child_count() == 0 then 78 | return false 79 | else 80 | local type = node:type() 81 | return type == 'anonymous_capturing_group' 82 | or type == 'alternation' 83 | or type == 'character_class' 84 | or type == 'lookaround_assertion' 85 | or type == 'named_capturing_group' 86 | or type == 'non_capturing_group' 87 | or type == 'pattern' 88 | or type == 'term' 89 | or false 90 | end 91 | end 92 | 93 | -- For reasons the author has yet to understand, punctuation like the opening of 94 | -- a named_capturing_group gets registered as components when traversing the tree. Let's exclude them. 95 | -- 96 | function M.is_punctuation(type) 97 | return type == '^' 98 | or type == '(' 99 | or type == ')' 100 | or type == '[' 101 | or type == ']' 102 | or type == '!' 103 | or type == '=' 104 | or type == '>' 105 | or type == '|' 106 | or type == '(?<' 107 | or type == '(?:' 108 | or type == '(?' 109 | or false 110 | end 111 | 112 | ---@param node TSNode 113 | ---@return unknown 114 | function M.is_control_escape(node) 115 | return require 'regexplainer.component'.is_control_escape { 116 | type = node:type(), 117 | text = get_node_text(node, 0), 118 | } 119 | end 120 | 121 | -- Is it a lookaround assertion? 122 | function M.is_lookaround_assertion(node) 123 | return require 'regexplainer.component.predicates'.is_lookaround_assertion { type = node:type() } 124 | end 125 | 126 | function M.is_modifier(node) 127 | return M.is_optional(node) 128 | or M.is_one_or_more(node) 129 | or M.is_zero_or_more(node) 130 | or M.is_count_quantifier(node) 131 | end 132 | 133 | --- Using treesitter, find the current node at cursor, and traverse up to the 134 | --- document root to determine if we're on a regexp 135 | ---@return TSNode|nil, integer|nil, string|nil 136 | -- 137 | function M.get_regexp_pattern_at_cursor() 138 | local parser = get_parser(0) 139 | parser:parse() 140 | local query = get_query(parser:lang(), 'regexplainer') 141 | if not query then 142 | return nil, nil, 'could not load regexplainer query for ' .. parser:lang() 143 | end 144 | local cursor_node = get_node() 145 | if not cursor_node then 146 | return nil, nil, 'could not get node at cursor' 147 | end 148 | local row, col = cursor_node:range() 149 | for id, node in query:iter_captures(cursor_node:tree():root(), 0, row, row + 1) do 150 | local name = query.captures[id] -- name of the capture in the query 151 | if name == 'regexplainer.pattern' and is_in_node_range(node, row, col) then 152 | return get_pattern(node) 153 | end 154 | end 155 | return nil, nil, 'no node' 156 | end 157 | 158 | return M 159 | -------------------------------------------------------------------------------- /plugin/nvim-regexplainer.lua: -------------------------------------------------------------------------------- 1 | local function command (name, callback, options) 2 | local final_opts = vim.tbl_deep_extend('force', options or {}, { bang = true }) 3 | vim.api.nvim_create_user_command(name, callback, final_opts) 4 | end 5 | 6 | local regexplainer = require'regexplainer' 7 | 8 | command('RegexplainerShow', function () regexplainer.show() end) 9 | command('RegexplainerHide', function () regexplainer.hide() end) 10 | command('RegexplainerToggle', function () regexplainer.toggle() end) 11 | 12 | command('RegexplainerYank', function (args) 13 | regexplainer.yank(args.args) 14 | end, { 15 | nargs = '*' 16 | }) 17 | 18 | command('RegexplainerShowSplit', function () regexplainer.show { 19 | display = 'split', 20 | } end) 21 | 22 | command('RegexplainerShowPopup', function () regexplainer.show { 23 | display = 'popup', 24 | } end) 25 | 26 | command('RegexplainerDebug', function () regexplainer.show { 27 | display = 'split', 28 | mode = 'debug', 29 | auto = false, 30 | } end) 31 | -------------------------------------------------------------------------------- /queries/ecma/regexplainer.scm: -------------------------------------------------------------------------------- 1 | (regex 2 | (regex_pattern) @regexplainer.pattern) @regexplainer.regex 3 | -------------------------------------------------------------------------------- /queries/javascript/regexplainer.scm: -------------------------------------------------------------------------------- 1 | ;; inherits: ecma 2 | -------------------------------------------------------------------------------- /queries/regex/regexplainer.scm: -------------------------------------------------------------------------------- 1 | (pattern) @regexplainer.pattern 2 | -------------------------------------------------------------------------------- /queries/typescript/regexplainer.scm: -------------------------------------------------------------------------------- 1 | ;; inherits: ecma 2 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/01 Simple Patterns.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `a` 3 | */ 4 | /a/; 5 | 6 | /** 7 | * `Hello` 8 | */ 9 | /Hello/; 10 | 11 | /** 12 | * `1` 13 | */ 14 | /\1/; 15 | 16 | /** 17 | * `1` 18 | */ 19 | /1/; 20 | 21 | /** 22 | * `123` 23 | */ 24 | /123/; 25 | 26 | /** 27 | * `123` 28 | */ 29 | /\1\2\3/; 30 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/02 Modifiers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `hello` 3 | * `!` (_optional_) 4 | */ 5 | /hello!?/; 6 | 7 | /** 8 | * `hello` 9 | * `.` (_optional_) 10 | */ 11 | /hello\.?/; 12 | 13 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/03 Ranges and Quantifiers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `@hello` 3 | * One of `a-z` 4 | */ 5 | /@hello[a-z]/; 6 | 7 | /** 8 | * One of **WB**, **WORD**, **0-9**, **WS**, **TAB**, **LF**, or **CR** 9 | */ 10 | /[\b\w\d\s\t\n\r]/; 11 | 12 | /** 13 | * **START** 14 | * One of `a-z`, `A-Z`, or `0-9` (_6-12x_) 15 | * **END** 16 | */ 17 | /^[a-zA-Z0-9]{6,12}$/; 18 | 19 | /** 20 | * One of `-`, **WORD**, or `.` 21 | */ 22 | /[-\w.]/; 23 | 24 | /** 25 | * One of **WORD**, `,`, or `.` 26 | */ 27 | /[\w,.]/; 28 | 29 | /** 30 | * One of **WORD**, `-`, or `.` 31 | */ 32 | /[\w\-.]/; 33 | 34 | /** 35 | * One of `.`, `-`, or **WORD** 36 | */ 37 | /[.\-\w]/; 38 | 39 | /** 40 | * **0-9** (_>= 0x_) 41 | * `(space)` 42 | */ 43 | /\d*\ /; 44 | 45 | /** 46 | * `a` (_1x_) 47 | * `b` (_>= 2x_) 48 | * `c` (_3-5x_) 49 | * `d` (_>= 0x_) 50 | * `e` (_>= 1x_) 51 | */ 52 | /a{1}b{2,}c{3,5}d*e+/g; 53 | 54 | /** 55 | * **WB** 56 | * One of `a-z`, `0-9`, `.`, `\_`, `%`, `+`, or `-` (_>= 1x_) 57 | * `@hello` 58 | * One of `a-z`, `0-9`, `.`, or `-` (_>= 1x_) 59 | * `.` 60 | * One of `a-z` (_>= 2x_) 61 | * **WB** 62 | */ 63 | /\b[a-z0-9._%+-]+@hello[a-z0-9.-]+\.[a-z]{2,}\b/; 64 | 65 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/04 Negated Ranges.js: -------------------------------------------------------------------------------- 1 | /** 2 | * **START** 3 | * `p` 4 | * Any except `p`, `^`, or `a` (_>= 0x_) 5 | * `p` 6 | */ 7 | /^p[^p^a]*p/; 8 | 9 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/05 Capture Groups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `@` 3 | * capture group 1: 4 | * `hello` 5 | */ 6 | /@(hello)/; 7 | 8 | /** 9 | * capture group 1: 10 | * **0-9** 11 | */ 12 | /(\d)/; 13 | 14 | /** 15 | * **WORD** (_>= 0x_) 16 | * capture group 1: 17 | * **WORD** 18 | */ 19 | /\w*(\w)/; 20 | 21 | /** 22 | * `@` 23 | * capture group 1: 24 | * `hello` 25 | * capture group 2: 26 | * `world` 27 | */ 28 | /@(hello)(world)/; 29 | 30 | /** 31 | * `zero` 32 | * capture group 1: 33 | * `one` 34 | * capture group 2: 35 | * `two` 36 | * capture group 3: 37 | * `three` 38 | */ 39 | /zero(one(two(three)))/; 40 | 41 | /** 42 | * `@` 43 | * capture group 1: 44 | * **WB** 45 | * **WORD** 46 | * **0-9** 47 | * **WS** 48 | * **TAB** 49 | * **LF** 50 | * **CR** 51 | */ 52 | /@(\b\w\d\s\t\n\r)/g; 53 | 54 | /** 55 | * `@` 56 | * capture group 1: 57 | * `a1` 58 | * **0-9** 59 | */ 60 | /@(a1\d)/g; 61 | 62 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/06 Named Capture Groups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * capture group 1 (_optional_): 3 | * `hello` 4 | * named capture group 2 `hello` (_>= 1x_): 5 | * `world` 6 | * non-capturing group (_2-3x_): 7 | * `one` 8 | */ 9 | /(hello)?(?world)+(?:one){2,3}/; 10 | 11 | /** 12 | * capture group 1: 13 | * One of `a-z`, or `a-z` (_2-5x_) 14 | * `a` 15 | * `-` (_optional_) 16 | * named capture group 2 `hello` (_4-5x_): 17 | * `world` 18 | */ 19 | /([a-za-z]{2,5})a-?(?world){4,5}/g; 20 | 21 | /** 22 | * capture group 1: 23 | * One of `a-z`, or `a-z` (_2-5x_) 24 | * `-` (_optional_) 25 | * named capture group 2 `dolly`: 26 | * **WB** 27 | * **WORD** 28 | * **0-9** 29 | * **WS** 30 | * **TAB** 31 | * **LF** 32 | * **CR** 33 | */ 34 | /([a-za-z]{2,5})-?(?\b\w\d\s\t\n\r)/g; 35 | 36 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/07 Non-Capturing Groups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `hello` 3 | * non-capturing group: 4 | * `world` 5 | */ 6 | /hello(?:world)/; 7 | 8 | /** 9 | * `hello` 10 | * non-capturing group: 11 | * `mudda` 12 | * non-capturing group: 13 | * `fadda` 14 | */ 15 | /hello(?:mudda(?:fadda))/; 16 | 17 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/08 Alternations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Either **WB**, **WORD**, **0-9**, **WS**, **TAB**, **LF**, or **CR** 3 | */ 4 | /\b|\w|\d|\s|\t|\n|\r/g; 5 | 6 | /** 7 | * Either `one` or `two` 8 | */ 9 | /one|two/; 10 | 11 | /** 12 | * Either `one`, `two`, or `three` 13 | */ 14 | /one|two|three/; 15 | 16 | /** 17 | * capture group 1: 18 | * Either `one`, `two`, or `three` 19 | */ 20 | /(one|two|three)/; 21 | 22 | /** 23 | * Either `zero`, `bupkis`, `gornisht`, or capture group 1: 24 | * Either `one`, `two`, `three`, or capture group 2: 25 | * Either `four` or `five` 26 | */ 27 | /zero|bupkis|gornisht|(one|two|three|(four|five))/; 28 | 29 | /** 30 | * `"` 31 | * capture group 1: 32 | * Either `http` or capture group 2: 33 | * `cs` 34 | * `s` 35 | * `"` 36 | * `;` (_optional_) 37 | */ 38 | /"(http|(cs)s)";?/; 39 | 40 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/09 Lookaround.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `@` 3 | * **followed by**: 4 | * `u` 5 | * `@` 6 | */ 7 | /@(?=u)@/; 8 | 9 | /** 10 | * `@` 11 | * **NOT followed by**: 12 | * `u` 13 | * `@` 14 | */ 15 | /@(?!u)@/; 16 | 17 | /** 18 | * `@` 19 | * **followed by** (_2-3x_): 20 | * Either `up` or `down` 21 | * `@` 22 | */ 23 | /@(?=up|down){2,3}@/; 24 | 25 | /** 26 | * `@` 27 | * **NOT followed by**: 28 | * One of **WORD**, or **WS** 29 | * `@` 30 | */ 31 | /@(?![\w\s])@/; 32 | 33 | /** 34 | * `@` 35 | * **followed by**: 36 | * `g` 37 | * non-capturing group (_optional_): 38 | * `raph` 39 | * `ql` 40 | * `@` 41 | */ 42 | /@(?=g(?:raph)?ql)@/; 43 | 44 | /** 45 | * **preceeding**: 46 | * `it's the ` 47 | * `attack of the killer tomatos` 48 | */ 49 | /(?<=it's the )attack of the killer tomatos/; 50 | 51 | /** 52 | * `x` 53 | * **NOT preceeding**: 54 | * `u` 55 | * `@` 56 | */ 57 | /x(?= 0x_) 70 | * `\`` 71 | */ 72 | /(?<=g)`(.*)`/mg; 73 | 74 | /** 75 | * **preceeding**: 76 | * `g` 77 | * non-capturing group (_optional_): 78 | * `raph` 79 | * `ql` 80 | * `\`` 81 | * capture group 1: 82 | * **ANY** (_>= 0x_) 83 | * `\`` 84 | */ 85 | /(?<=g(?:raph)?ql)`(.*)`/mg; 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/10 Special Characters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * **START** 3 | * `ok` 4 | * **ANY** (_optional_) 5 | * **END** 6 | */ 7 | /^ok.?$/; 8 | 9 | /** 10 | * **WB** 11 | * **WORD** 12 | * **0-9** 13 | * **WS** 14 | * **TAB** 15 | * **CR** 16 | * **LF** 17 | */ 18 | /\b\w\d\s\t\r\n/; 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/11 Practical Examples.js: -------------------------------------------------------------------------------- 1 | /** 2 | * **START** 3 | * `@scope/` 4 | * capture group 1: 5 | * **ANY** (_>= 0x_) 6 | * `.js"` 7 | * `;` (_optional_) 8 | * **END** 9 | */ 10 | /^@scope\/(.*)\.js";?$/; 11 | 12 | /** 13 | * `@scope/` 14 | * capture group 1: 15 | * **ANY** (_>= 0x_) 16 | * `.` 17 | * named capture group 2 `extension`: 18 | * Either `graphql` or non-capturing group: 19 | * Either `t`, `j`, or `cs` 20 | * `s` 21 | * `"` 22 | * `;` (_optional_) 23 | */ 24 | /@scope\/(.*)\.(?graphql|(?:t|j|cs)s)";?/; 25 | 26 | /** 27 | * `\` 33 | */ 34 | //; 35 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/12 Regex Sudoku.js: -------------------------------------------------------------------------------- 1 | /** 2 | * capture group 1: 3 | * **WORD** 4 | */ 5 | /(\w)/; 6 | 7 | /** 8 | * **0-9** (_>= 0x_) 9 | * capture group 1: 10 | * **0-9** 11 | */ 12 | /\d*(\d)/; 13 | 14 | /** 15 | * **NOT followed by**: 16 | * non-capturing group (_>= 1x_): 17 | * **ANY** (_>= 0x_) 18 | * **LF** 19 | * non-capturing group (_0x_): 20 | * **ANY** (_10x_) 21 | * `1` 22 | * **WB** 23 | */ 24 | /(?!(?:.*\n)+(?:.{10}){0}\1\b)/; 25 | 26 | /** 27 | * **NOT followed by**: 28 | * **0-9** (_>= 0x_) 29 | * `(space)` 30 | * non-capturing group (_>= 0x_) (_lazy_): 31 | * **ANY** (_10x_) 32 | * `1` 33 | * **WB** 34 | */ 35 | /(?!\d*\ (?:.{10})*?\1\b)/; 36 | 37 | /** 38 | * **NOT followed by**: 39 | * **0-9** (_>= 0x_) 40 | * `(space)` 41 | * non-capturing group (_0-1x_): 42 | * **ANY** (_10x_) 43 | * `1` 44 | * **WB** 45 | */ 46 | /(?!\d*\ (?:.{10}){0,1}\1\b)/; 47 | 48 | /** 49 | * **NOT followed by**: 50 | * non-capturing group (_1-2x_): 51 | * **ANY** (_>= 0x_) 52 | * **LF** 53 | * non-capturing group (_0x_): 54 | * **ANY** (_30x_) 55 | * non-capturing group (_0-2x_): 56 | * **ANY** (_10x_) 57 | * `1` 58 | * **WB** 59 | */ 60 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\1\b)/; 61 | 62 | /** 63 | * **0-9** (_>= 0x_) 64 | * **WS** (_>= 1x_) 65 | */ 66 | /\d*\s+/; 67 | 68 | /** 69 | * **0-9** (_>= 0x_) 70 | * **NOT followed by**: 71 | * `1` 72 | */ 73 | /\d*(?!\1)/; 74 | 75 | /** 76 | * **NOT followed by**: 77 | * non-capturing group (_>= 1x_): 78 | * **ANY** (_>= 0x_) 79 | * **LF** 80 | * non-capturing group (_1x_): 81 | * **ANY** (_10x_) 82 | * `2` 83 | * **WB** 84 | */ 85 | /(?!(?:.*\n)+(?:.{10}){1}\2\b)/; 86 | 87 | /** 88 | * **NOT followed by**: 89 | * **0-9** (_>= 0x_) 90 | * `(space)` 91 | * non-capturing group (_>= 0x_) (_lazy_): 92 | * **ANY** (_10x_) 93 | * `2` 94 | * **WB** 95 | */ 96 | /(?!\d*\ (?:.{10})*?\2\b)/; 97 | 98 | /** 99 | * **NOT followed by**: 100 | * **0-9** (_>= 0x_) 101 | * `(space)` 102 | * non-capturing group (_0-0x_): 103 | * **ANY** (_10x_) 104 | * `2` 105 | * **WB** 106 | */ 107 | /(?!\d*\ (?:.{10}){0,0}\2\b)/; 108 | 109 | /** 110 | * **NOT followed by**: 111 | * non-capturing group (_1-2x_): 112 | * **ANY** (_>= 0x_) 113 | * **LF** 114 | * non-capturing group (_0x_): 115 | * **ANY** (_30x_) 116 | * non-capturing group (_0-2x_): 117 | * **ANY** (_10x_) 118 | * `2` 119 | * **WB** 120 | */ 121 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\2\b)/; 122 | 123 | /** 124 | * **0-9** (_>= 0x_) 125 | * **WS** (_>= 1x_) 126 | */ 127 | /\d*\s+/; 128 | 129 | /** 130 | * **0-9** (_>= 0x_) 131 | * **NOT followed by**: 132 | * Either `1` or `2` 133 | */ 134 | /\d*(?!\1|\2)/; 135 | 136 | /** 137 | * **NOT followed by**: 138 | * non-capturing group (_>= 1x_): 139 | * **ANY** (_>= 0x_) 140 | * **LF** 141 | * non-capturing group (_2x_): 142 | * **ANY** (_10x_) 143 | * `3` 144 | * **WB** 145 | */ 146 | /(?!(?:.*\n)+(?:.{10}){2}\3\b)/; 147 | 148 | /** 149 | * **NOT followed by**: 150 | * **0-9** (_>= 0x_) 151 | * `(space)` 152 | * non-capturing group (_>= 0x_) (_lazy_): 153 | * **ANY** (_10x_) 154 | * `3` 155 | * **WB** 156 | */ 157 | /(?!\d*\ (?:.{10})*?\3\b)/; 158 | 159 | /** 160 | * **NOT followed by**: 161 | * non-capturing group (_1-2x_): 162 | * **ANY** (_>= 0x_) 163 | * **LF** 164 | * non-capturing group (_0x_): 165 | * **ANY** (_30x_) 166 | * non-capturing group (_0-2x_): 167 | * **ANY** (_10x_) 168 | * `3` 169 | * **WB** 170 | */ 171 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\3\b)/; 172 | 173 | /** 174 | * `split` 175 | * **0-9** (_>= 0x_) 176 | * **WS** (_>= 1x_) 177 | */ 178 | /split\d*\s+/; 179 | 180 | /** 181 | * **0-9** (_>= 0x_) 182 | * **NOT followed by**: 183 | * Either `1`, `2`, or `3` 184 | */ 185 | /\d*(?!\1|\2|\3)/; 186 | 187 | /** 188 | * **NOT followed by**: 189 | * non-capturing group (_>= 1x_): 190 | * **ANY** (_>= 0x_) 191 | * **LF** 192 | * non-capturing group (_3x_): 193 | * **ANY** (_10x_) 194 | * `4` 195 | * **WB** 196 | */ 197 | /(?!(?:.*\n)+(?:.{10}){3}\4\b)/; 198 | 199 | /** 200 | * **NOT followed by**: 201 | * **0-9** (_>= 0x_) 202 | * `(space)` 203 | * non-capturing group (_>= 0x_) (_lazy_): 204 | * **ANY** (_10x_) 205 | * `4` 206 | * **WB** 207 | */ 208 | /(?!\d*\ (?:.{10})*?\4\b)/; 209 | 210 | /** 211 | * **NOT followed by**: 212 | * **0-9** (_>= 0x_) 213 | * `(space)` 214 | * non-capturing group (_0-1x_): 215 | * **ANY** (_10x_) 216 | * `4` 217 | * **WB** 218 | */ 219 | /(?!\d*\ (?:.{10}){0,1}\4\b)/; 220 | 221 | /** 222 | * **NOT followed by**: 223 | * non-capturing group (_1-2x_): 224 | * **ANY** (_>= 0x_) 225 | * **LF** 226 | * non-capturing group (_1x_): 227 | * **ANY** (_30x_) 228 | * non-capturing group (_0-2x_): 229 | * **ANY** (_10x_) 230 | * `4` 231 | * **WB** 232 | */ 233 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\4\b)/; 234 | 235 | /\d*(?!\1|\2|\3|\4)/; 236 | /(?!(?:.*\n)+(?:.{10}){4}\5\b)/; 237 | /(?!\d*\ (?:.{10})*?\5\b)/; 238 | /(?!\d*\ (?:.{10}){0,0}\5\b)/; 239 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\5\b)/; 240 | 241 | /\d*(?!\1|\2|\3|\4|\5)/; 242 | /(?!(?:.*\n)+(?:.{10}){5}\6\b)/; 243 | /(?!\d*\ (?:.{10})*?\6\b)/; 244 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\6\b)/; 245 | 246 | /\d*(?!\1|\2|\3|\4|\5|\6)/; 247 | /(?!(?:.*\n)+(?:.{10}){6}\7\b)/; 248 | /(?!\d*\ (?:.{10})*?\7\b)/; 249 | /(?!\d*\ (?:.{10}){0,1}\7\b)/; 250 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\7\b)/; 251 | 252 | /\d*(?!\1|\2|\3|\4|\5|\6|\7)/; 253 | /(?!(?:.*\n)+(?:.{10}){7}\8\b)/; 254 | /(?!\d*\ (?:.{10})*?\8\b)/; 255 | /(?!\d*\ (?:.{10}){0,0}\8\b)/; 256 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\8\b)/; 257 | 258 | /\d*(?!\1|\2|\3|\4|\5|\6|\7|\8)/; 259 | /(?!(?:.*\n)+(?:.{10}){8}\9\b)/; 260 | /(?!\d*\ (?:.{10})*?\9\b)/; 261 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\9\b)/; 262 | 263 | /(?!(?:.*\n)+(?:.{10}){0}\10\b)/; 264 | /(?!\d*\ (?:.{10})*?\10\b)/; 265 | /(?!\d*\ (?:.{10}){0,1}\10\b)/; 266 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\10\b)/; 267 | 268 | /\d*(?!\1|\2|\3|\10)/; 269 | /(?!(?:.*\n)+(?:.{10}){1}\11\b)/; 270 | /(?!\d*\ (?:.{10})*?\11\b)/; 271 | /(?!\d*\ (?:.{10}){0,0}\11\b)/; 272 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\11\b)/; 273 | 274 | /\d*(?!\1|\2|\3|\10|\11)/; 275 | /(?!(?:.*\n)+(?:.{10}){2}\12\b)/; 276 | /(?!\d*\ (?:.{10})*?\12\b)/; 277 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\12\b)/; 278 | 279 | /\d*(?!\4|\5|\6|\10|\11|\12)/; 280 | /(?!(?:.*\n)+(?:.{10}){3}\13\b)/; 281 | /(?!\d*\ (?:.{10})*?\13\b)/; 282 | /(?!\d*\ (?:.{10}){0,1}\13\b)/; 283 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\13\b)/; 284 | 285 | /\d*(?!\4|\5|\6|\10|\11|\12|\13)/; 286 | /(?!(?:.*\n)+(?:.{10}){4}\14\b)/; 287 | /(?!\d*\ (?:.{10})*?\14\b)/; 288 | /(?!\d*\ (?:.{10}){0,0}\14\b)/; 289 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\14\b)/; 290 | 291 | /\d*(?!\4|\5|\6|\10|\11|\12|\13|\14)/; 292 | /(?!(?:.*\n)+(?:.{10}){5}\15\b)/; 293 | /(?!\d*\ (?:.{10})*?\15\b)/; 294 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\15\b)/; 295 | 296 | /\d*(?!\7|\8|\9|\10|\11|\12|\13|\14|\15)/; 297 | /(?!(?:.*\n)+(?:.{10}){6}\16\b)/; 298 | /(?!\d*\ (?:.{10})*?\16\b)/; 299 | /(?!\d*\ (?:.{10}){0,1}\16\b)/; 300 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\16\b)/; 301 | 302 | /\d*(?!\7|\8|\9|\10|\11|\12|\13|\14|\15|\16)/; 303 | /(?!(?:.*\n)+(?:.{10}){7}\17\b)/; 304 | /(?!\d*\ (?:.{10})*?\17\b)/; 305 | /(?!\d*\ (?:.{10}){0,0}\17\b)/; 306 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\17\b)/; 307 | 308 | /\d*(?!\7|\8|\9|\10|\11|\12|\13|\14|\15|\16|\17)/; 309 | /(?!(?:.*\n)+(?:.{10}){8}\18\b)/; 310 | /(?!\d*\ (?:.{10})*?\18\b)/; 311 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\18\b)/; 312 | 313 | /\d*(?!\1|\2|\3|\10|\11|\12)/; 314 | /(?!(?:.*\n)+(?:.{10}){0}\19\b)/; 315 | /(?!\d*\ (?:.{10})*?\19\b)/; 316 | /(?!\d*\ (?:.{10}){0,1}\19\b)/; 317 | 318 | /\d*(?!\1|\2|\3|\10|\11|\12|\19)/; 319 | /(?!(?:.*\n)+(?:.{10}){1}\20\b)/; 320 | /(?!\d*\ (?:.{10})*?\20\b)/; 321 | /(?!\d*\ (?:.{10}){0,0}\20\b)/; 322 | 323 | /\d*(?!\1|\2|\3|\10|\11|\12|\19|\20)/; 324 | /(?!(?:.*\n)+(?:.{10}){2}\21\b)/; 325 | /(?!\d*\ (?:.{10})*?\21\b)/; 326 | 327 | /\d*(?!\4|\5|\6|\13|\14|\15|\19|\20|\21)/; 328 | /(?!(?:.*\n)+(?:.{10}){3}\22\b)/; 329 | /(?!\d*\ (?:.{10})*?\22\b)/; 330 | /(?!\d*\ (?:.{10}){0,1}\22\b)/; 331 | 332 | /\d*(?!\4|\5|\6|\13|\14|\15|\19|\20|\21|\22)/; 333 | /(?!(?:.*\n)+(?:.{10}){4}\23\b)/; 334 | /(?!\d*\ (?:.{10})*?\23\b)/; 335 | /(?!\d*\ (?:.{10}){0,0}\23\b)/; 336 | 337 | /\d*(?!\4|\5|\6|\13|\14|\15|\19|\20|\21|\22|\23)/; 338 | /(?!(?:.*\n)+(?:.{10}){5}\24\b)/; 339 | /(?!\d*\ (?:.{10})*?\24\b)/; 340 | 341 | /\d*(?!\7|\8|\9|\16|\17|\18|\19|\20|\21|\22|\23|\24)/; 342 | /(?!(?:.*\n)+(?:.{10}){6}\25\b)/; 343 | /(?!\d*\ (?:.{10})*?\25\b)/; 344 | /(?!\d*\ (?:.{10}){0,1}\25\b)/; 345 | 346 | /\d*(?!\7|\8|\9|\16|\17|\18|\19|\20|\21|\22|\23|\24|\25)/; 347 | /(?!(?:.*\n)+(?:.{10}){7}\26\b)/; 348 | /(?!\d*\ (?:.{10})*?\26\b)/; 349 | /(?!\d*\ (?:.{10}){0,0}\26\b)/; 350 | 351 | /\d*(?!\7|\8|\9|\16|\17|\18|\19|\20|\21|\22|\23|\24|\25|\26)/; 352 | /(?!(?:.*\n)+(?:.{10}){8}\27\b)/; 353 | /(?!\d*\ (?:.{10})*?\27\b)/; 354 | 355 | /\d*(?!\1|\10|\19)/; 356 | /(?!(?:.*\n)+(?:.{10}){0}\28\b)/; 357 | /(?!\d*\ (?:.{10})*?\28\b)/; 358 | /(?!\d*\ (?:.{10}){0,1}\28\b)/; 359 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\28\b)/; 360 | 361 | /\d*(?!\2|\11|\20|\28)/; 362 | /(?!(?:.*\n)+(?:.{10}){1}\29\b)/; 363 | /(?!\d*\ (?:.{10})*?\29\b)/; 364 | /(?!\d*\ (?:.{10}){0,0}\29\b)/; 365 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\29\b)/; 366 | 367 | /\d*(?!\3|\12|\21|\28|\29)/; 368 | /(?!(?:.*\n)+(?:.{10}){2}\30\b)/; 369 | /(?!\d*\ (?:.{10})*?\30\b)/; 370 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\30\b)/; 371 | 372 | /\d*(?!\4|\13|\22|\28|\29|\30)/; 373 | /(?!(?:.*\n)+(?:.{10}){3}\31\b)/; 374 | /(?!\d*\ (?:.{10})*?\31\b)/; 375 | /(?!\d*\ (?:.{10}){0,1}\31\b)/; 376 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\31\b)/; 377 | 378 | /\d*(?!\5|\14|\23|\28|\29|\30|\31)/; 379 | /(?!(?:.*\n)+(?:.{10}){4}\32\b)/; 380 | /(?!\d*\ (?:.{10})*?\32\b)/; 381 | /(?!\d*\ (?:.{10}){0,0}\32\b)/; 382 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\32\b)/; 383 | 384 | /\d*(?!\6|\15|\24|\28|\29|\30|\31|\32)/; 385 | /(?!(?:.*\n)+(?:.{10}){5}\33\b)/; 386 | /(?!\d*\ (?:.{10})*?\33\b)/; 387 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\33\b)/; 388 | 389 | /\d*(?!\7|\16|\25|\28|\29|\30|\31|\32|\33)/; 390 | /(?!(?:.*\n)+(?:.{10}){6}\34\b)/; 391 | /(?!\d*\ (?:.{10})*?\34\b)/; 392 | /(?!\d*\ (?:.{10}){0,1}\34\b)/; 393 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\34\b)/; 394 | 395 | /\d*(?!\8|\17|\26|\28|\29|\30|\31|\32|\33|\34)/; 396 | /(?!(?:.*\n)+(?:.{10}){7}\35\b)/; 397 | /(?!\d*\ (?:.{10})*?\35\b)/; 398 | /(?!\d*\ (?:.{10}){0,0}\35\b)/; 399 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\35\b)/; 400 | 401 | /\d*(?!\9|\18|\27|\28|\29|\30|\31|\32|\33|\34|\35)/; 402 | /(?!(?:.*\n)+(?:.{10}){8}\36\b)/; 403 | /(?!\d*\ (?:.{10})*?\36\b)/; 404 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\36\b)/; 405 | 406 | /\d*(?!\1|\10|\19|\28|\29|\30)/; 407 | /(?!(?:.*\n)+(?:.{10}){0}\37\b)/; 408 | /(?!\d*\ (?:.{10})*?\37\b)/; 409 | /(?!\d*\ (?:.{10}){0,1}\37\b)/; 410 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\37\b)/; 411 | 412 | /\d*(?!\2|\11|\20|\28|\29|\30|\37)/; 413 | /(?!(?:.*\n)+(?:.{10}){1}\38\b)/; 414 | /(?!\d*\ (?:.{10})*?\38\b)/; 415 | /(?!\d*\ (?:.{10}){0,0}\38\b)/; 416 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\38\b)/; 417 | 418 | /\d*(?!\3|\12|\21|\28|\29|\30|\37|\38)/; 419 | /(?!(?:.*\n)+(?:.{10}){2}\39\b)/; 420 | /(?!\d*\ (?:.{10})*?\39\b)/; 421 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\39\b)/; 422 | 423 | /\d*(?!\4|\13|\22|\31|\32|\33|\37|\38|\39)/; 424 | /(?!(?:.*\n)+(?:.{10}){3}\40\b)/; 425 | /(?!\d*\ (?:.{10})*?\40\b)/; 426 | /(?!\d*\ (?:.{10}){0,1}\40\b)/; 427 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\40\b)/; 428 | 429 | /\d*(?!\5|\14|\23|\31|\32|\33|\37|\38|\39|\40)/; 430 | /(?!(?:.*\n)+(?:.{10}){4}\41\b)/; 431 | /(?!\d*\ (?:.{10})*?\41\b)/; 432 | /(?!\d*\ (?:.{10}){0,0}\41\b)/; 433 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\41\b)/; 434 | 435 | /\d*(?!\6|\15|\24|\31|\32|\33|\37|\38|\39|\40|\41)/; 436 | /(?!(?:.*\n)+(?:.{10}){5}\42\b)/; 437 | /(?!\d*\ (?:.{10})*?\42\b)/; 438 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\42\b)/; 439 | 440 | /\d*(?!\7|\16|\25|\34|\35|\36|\37|\38|\39|\40|\41|\42)/; 441 | /(?!(?:.*\n)+(?:.{10}){6}\43\b)/; 442 | /(?!\d*\ (?:.{10})*?\43\b)/; 443 | /(?!\d*\ (?:.{10}){0,1}\43\b)/; 444 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\43\b)/; 445 | 446 | /\d*(?!\8|\17|\26|\34|\35|\36|\37|\38|\39|\40|\41|\42|\43)/; 447 | /(?!(?:.*\n)+(?:.{10}){7}\44\b)/; 448 | /(?!\d*\ (?:.{10})*?\44\b)/; 449 | /(?!\d*\ (?:.{10}){0,0}\44\b)/; 450 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\44\b)/; 451 | 452 | /\d*(?!\9|\18|\27|\34|\35|\36|\37|\38|\39|\40|\41|\42|\43|\44)/; 453 | /(?!(?:.*\n)+(?:.{10}){8}\45\b)/; 454 | /(?!\d*\ (?:.{10})*?\45\b)/; 455 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\45\b)/; 456 | 457 | /\d*(?!\1|\10|\19|\28|\29|\30|\37|\38|\39)/; 458 | /(?!(?:.*\n)+(?:.{10}){0}\46\b)/; 459 | /(?!\d*\ (?:.{10})*?\46\b)/; 460 | /(?!\d*\ (?:.{10}){0,1}\46\b)/; 461 | 462 | /\d*(?!\2|\11|\20|\28|\29|\30|\37|\38|\39|\46)/; 463 | /(?!(?:.*\n)+(?:.{10}){1}\47\b)/; 464 | /(?!\d*\ (?:.{10})*?\47\b)/; 465 | /(?!\d*\ (?:.{10}){0,0}\47\b)/; 466 | 467 | /\d*(?!\3|\12|\21|\28|\29|\30|\37|\38|\39|\46|\47)/; 468 | /(?!(?:.*\n)+(?:.{10}){2}\48\b)/; 469 | /(?!\d*\ (?:.{10})*?\48\b)/; 470 | 471 | /\d*(?!\4|\13|\22|\31|\32|\33|\40|\41|\42|\46|\47|\48)/; 472 | /(?!(?:.*\n)+(?:.{10}){3}\49\b)/; 473 | /(?!\d*\ (?:.{10})*?\49\b)/; 474 | /(?!\d*\ (?:.{10}){0,1}\49\b)/; 475 | 476 | /\d*(?!\5|\14|\23|\31|\32|\33|\40|\41|\42|\46|\47|\48|\49)/; 477 | /(?!(?:.*\n)+(?:.{10}){4}\50\b)/; 478 | /(?!\d*\ (?:.{10})*?\50\b)/; 479 | /(?!\d*\ (?:.{10}){0,0}\50\b)/; 480 | 481 | /\d*(?!\6|\15|\24|\31|\32|\33|\40|\41|\42|\46|\47|\48|\49|\50)/; 482 | /(?!(?:.*\n)+(?:.{10}){5}\51\b)/; 483 | /(?!\d*\ (?:.{10})*?\51\b)/; 484 | 485 | /\d*(?!\7|\16|\25|\34|\35|\36|\43|\44|\45|\46|\47|\48|\49|\50|\51)/; 486 | /(?!(?:.*\n)+(?:.{10}){6}\52\b)/; 487 | /(?!\d*\ (?:.{10})*?\52\b)/; 488 | /(?!\d*\ (?:.{10}){0,1}\52\b)/; 489 | 490 | /\d*(?!\8|\17|\26|\34|\35|\36|\43|\44|\45|\46|\47|\48|\49|\50|\51|\52)/; 491 | /(?!(?:.*\n)+(?:.{10}){7}\53\b)/; 492 | /(?!\d*\ (?:.{10})*?\53\b)/; 493 | /(?!\d*\ (?:.{10}){0,0}\53\b)/; 494 | 495 | /\d*(?!\9|\18|\27|\34|\35|\36|\43|\44|\45|\46|\47|\48|\49|\50|\51|\52|\53)/; 496 | /(?!(?:.*\n)+(?:.{10}){8}\54\b)/; 497 | /(?!\d*\ (?:.{10})*?\54\b)/; 498 | 499 | /\d*(?!\1|\10|\19|\28|\37|\46)/; 500 | /(?!(?:.*\n)+(?:.{10}){0}\55\b)/; 501 | /(?!\d*\ (?:.{10})*?\55\b)/; 502 | /(?!\d*\ (?:.{10}){0,1}\55\b)/; 503 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\55\b)/; 504 | 505 | /\d*(?!\2|\11|\20|\29|\38|\47|\55)/; 506 | /(?!(?:.*\n)+(?:.{10}){1}\56\b)/; 507 | /(?!\d*\ (?:.{10})*?\56\b)/; 508 | /(?!\d*\ (?:.{10}){0,0}\56\b)/; 509 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\56\b)/; 510 | 511 | /\d*(?!\3|\12|\21|\30|\39|\48|\55|\56)/; 512 | /(?!(?:.*\n)+(?:.{10}){2}\57\b)/; 513 | /(?!\d*\ (?:.{10})*?\57\b)/; 514 | /(?!(?:.*\n){1,2}(?:.{30}){0}(?:.{10}){0,2}\57\b)/; 515 | 516 | /\d*(?!\4|\13|\22|\31|\40|\49|\55|\56|\57)/; 517 | /(?!(?:.*\n)+(?:.{10}){3}\58\b)/; 518 | /(?!\d*\ (?:.{10})*?\58\b)/; 519 | /(?!\d*\ (?:.{10}){0,1}\58\b)/; 520 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\58\b)/; 521 | 522 | /\d*(?!\5|\14|\23|\32|\41|\50|\55|\56|\57|\58)/; 523 | /(?!(?:.*\n)+(?:.{10}){4}\59\b)/; 524 | /(?!\d*\ (?:.{10})*?\59\b)/; 525 | /(?!\d*\ (?:.{10}){0,0}\59\b)/; 526 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\59\b)/; 527 | 528 | /\d*(?!\6|\15|\24|\33|\42|\51|\55|\56|\57|\58|\59)/; 529 | /(?!(?:.*\n)+(?:.{10}){5}\60\b)/; 530 | /(?!\d*\ (?:.{10})*?\60\b)/; 531 | /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\60\b)/; 532 | 533 | /\d*(?!\7|\16|\25|\34|\43|\52|\55|\56|\57|\58|\59|\60)/; 534 | /(?!(?:.*\n)+(?:.{10}){6}\61\b)/; 535 | /(?!\d*\ (?:.{10})*?\61\b)/; 536 | /(?!\d*\ (?:.{10}){0,1}\61\b)/; 537 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\61\b)/; 538 | 539 | /\d*(?!\8|\17|\26|\35|\44|\53|\55|\56|\57|\58|\59|\60|\61)/; 540 | /(?!(?:.*\n)+(?:.{10}){7}\62\b)/; 541 | /(?!\d*\ (?:.{10})*?\62\b)/; 542 | /(?!\d*\ (?:.{10}){0,0}\62\b)/; 543 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\62\b)/; 544 | 545 | /\d*(?!\9|\18|\27|\36|\45|\54|\55|\56|\57|\58|\59|\60|\61|\62)/; 546 | /(?!(?:.*\n)+(?:.{10}){8}\63\b)/; 547 | /(?!\d*\ (?:.{10})*?\63\b)/; 548 | /(?!(?:.*\n){1,2}(?:.{30}){2}(?:.{10}){0,2}\63\b)/; 549 | 550 | /\d*(?!\1|\10|\19|\28|\37|\46|\55|\56|\57)/; 551 | /(?!(?:.*\n)+(?:.{10}){0}\64\b)/; 552 | /(?!\d*\ (?:.{10})*?\64\b)/; 553 | /(?!\d*\ (?:.{10}){0,1}\64\b)/; 554 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\64\b)/; 555 | 556 | /\d*(?!\2|\11|\20|\29|\38|\47|\55|\56|\57|\64)/; 557 | /(?!(?:.*\n)+(?:.{10}){1}\65\b)/; 558 | /(?!\d*\ (?:.{10})*?\65\b)/; 559 | /(?!\d*\ (?:.{10}){0,0}\65\b)/; 560 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\65\b)/; 561 | 562 | /\d*(?!\3|\12|\21|\30|\39|\48|\55|\56|\57|\64|\65)/; 563 | /(?!(?:.*\n)+(?:.{10}){2}\66\b)/; 564 | /(?!\d*\ (?:.{10})*?\66\b)/; 565 | /(?!(?:.*\n){1,1}(?:.{30}){0}(?:.{10}){0,2}\66\b)/; 566 | 567 | /\d*(?!\4|\13|\22|\31|\40|\49|\58|\59|\60|\64|\65|\66)/; 568 | /(?!(?:.*\n)+(?:.{10}){3}\67\b)/; 569 | /(?!\d*\ (?:.{10})*?\67\b)/; 570 | /(?!\d*\ (?:.{10}){0,1}\67\b)/; 571 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\67\b)/; 572 | 573 | /\d*(?!\5|\14|\23|\32|\41|\50|\58|\59|\60|\64|\65|\66|\67)/; 574 | /(?!(?:.*\n)+(?:.{10}){4}\68\b)/; 575 | /(?!\d*\ (?:.{10})*?\68\b)/; 576 | /(?!\d*\ (?:.{10}){0,0}\68\b)/; 577 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\68\b)/; 578 | 579 | /\d*(?!\6|\15|\24|\33|\42|\51|\58|\59|\60|\64|\65|\66|\67|\68)/; 580 | /(?!(?:.*\n)+(?:.{10}){5}\69\b)/; 581 | /(?!\d*\ (?:.{10})*?\69\b)/; 582 | /(?!(?:.*\n){1,1}(?:.{30}){1}(?:.{10}){0,2}\69\b)/; 583 | 584 | /\d*(?!\7|\16|\25|\34|\43|\52|\61|\62|\63|\64|\65|\66|\67|\68|\69)/; 585 | /(?!(?:.*\n)+(?:.{10}){6}\70\b)/; 586 | /(?!\d*\ (?:.{10})*?\70\b)/; 587 | /(?!\d*\ (?:.{10}){0,1}\70\b)/; 588 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\70\b)/; 589 | 590 | /\d*(?!\8|\17|\26|\35|\44|\53|\61|\62|\63|\64|\65|\66|\67|\68|\69|\70)/; 591 | /(?!(?:.*\n)+(?:.{10}){7}\71\b)/; 592 | /(?!\d*\ (?:.{10})*?\71\b)/; 593 | /(?!\d*\ (?:.{10}){0,0}\71\b)/; 594 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\71\b)/; 595 | 596 | /\d*(?!\9|\18|\27|\36|\45|\54|\61|\62|\63|\64|\65|\66|\67|\68|\69|\70|\71)/; 597 | /(?!(?:.*\n)+(?:.{10}){8}\72\b)/; 598 | /(?!\d*\ (?:.{10})*?\72\b)/; 599 | /(?!(?:.*\n){1,1}(?:.{30}){2}(?:.{10}){0,2}\72\b)/; 600 | 601 | /\d*(?!\1|\10|\19|\28|\37|\46|\55|\56|\57|\64|\65|\66)/; 602 | /(?!(?:.*\n)+(?:.{10}){0}\73\b)/; 603 | /(?!\d*\ (?:.{10})*?\73\b)/; 604 | /(?!\d*\ (?:.{10}){0,1}\73\b)/; 605 | 606 | /\d*(?!\2|\11|\20|\29|\38|\47|\55|\56|\57|\64|\65|\66|\73)/; 607 | /(?!(?:.*\n)+(?:.{10}){1}\74\b)/; 608 | /(?!\d*\ (?:.{10})*?\74\b)/; 609 | /(?!\d*\ (?:.{10}){0,0}\74\b)/; 610 | 611 | /\d*(?!\3|\12|\21|\30|\39|\48|\55|\56|\57|\64|\65|\66|\73|\74)/; 612 | /(?!(?:.*\n)+(?:.{10}){2}\75\b)/; 613 | /(?!\d*\ (?:.{10})*?\75\b)/; 614 | 615 | /\d*(?!\4|\13|\22|\31|\40|\49|\58|\59|\60|\67|\68|\69|\73|\74|\75)/; 616 | /(?!(?:.*\n)+(?:.{10}){3}\76\b)/; 617 | /(?!\d*\ (?:.{10})*?\76\b)/; 618 | /(?!\d*\ (?:.{10}){0,1}\76\b)/; 619 | 620 | /\d*(?!\5|\14|\23|\32|\41|\50|\58|\59|\60|\67|\68|\69|\73|\74|\75|\76)/; 621 | /(?!(?:.*\n)+(?:.{10}){4}\77\b)/; 622 | /(?!\d*\ (?:.{10})*?\77\b)/; 623 | /(?!\d*\ (?:.{10}){0,0}\77\b)/; 624 | 625 | /\d*(?!\6|\15|\24|\33|\42|\51|\58|\59|\60|\67|\68|\69|\73|\74|\75|\76|\77)/; 626 | /(?!(?:.*\n)+(?:.{10}){5}\78\b)/; 627 | /(?!\d*\ (?:.{10})*?\78\b)/; 628 | 629 | /\d*(?!\7|\16|\25|\34|\43|\52|\61|\62|\63|\70|\71|\72|\73|\74|\75|\76|\77|\78)/; 630 | /(?!(?:.*\n)+(?:.{10}){6}\79\b)/; 631 | /(?!\d*\ (?:.{10})*?\79\b)/; 632 | /(?!\d*\ (?:.{10}){0,1}\79\b)/; 633 | 634 | /\d*(?!\8|\17|\26|\35|\44|\53|\61|\62|\63|\70|\71|\72|\73|\74|\75|\76|\77|\78|\79)/; 635 | /(?!(?:.*\n)+(?:.{10}){7}\80\b)/; 636 | /(?!\d*\ (?:.{10})*?\80\b)/; 637 | /(?!\d*\ (?:.{10}){0,0}\80\b)/; 638 | 639 | /\d*(?!\9|\18|\27|\36|\45|\54|\61|\62|\63|\70|\71|\72|\73|\74|\75|\76|\77|\78|\79|\80)/; 640 | /(?!(?:.*\n)+(?:.{10}){8}\81\b)/; 641 | /(?!\d*\ (?:.{10})*?\81\b)/; 642 | -------------------------------------------------------------------------------- /tests/fixtures/narrative/13 Errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 🚨 **Regexp contains an ERROR** at 3 | * `@scope\/(.*)\.{graphql,js,ts,css}` 4 | * ^ 5 | */ 6 | /@scope\/(.*)\.{graphql,js,ts,css}/; 7 | 8 | -------------------------------------------------------------------------------- /tests/helpers/util.lua: -------------------------------------------------------------------------------- 1 | local regexplainer = require 'regexplainer' 2 | local buffers = require 'regexplainer.buffers' 3 | local parsers = require "nvim-treesitter.parsers" 4 | 5 | local get_parser = vim.treesitter.get_parser 6 | local get_node_text = vim.treesitter.get_node_text 7 | local bd = vim.api.nvim_buf_delete 8 | 9 | local query = vim.treesitter.query.get('javascript', 'regexplainer_test') 10 | if not query then error('could not get query') end 11 | 12 | local function trim(s) 13 | return (string.gsub(s, "^%s*(.-)%s*$", "%1")) 14 | end 15 | 16 | local function editfile(testfile) 17 | vim.cmd("e! " .. testfile) 18 | assert.are.same( 19 | vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ":p"), 20 | vim.fn.fnamemodify(testfile, ":p") 21 | ) 22 | end 23 | 24 | ---Parse a JSDoc comment, returning the markdown description 25 | ---@param comment string JSDoc comment, including /* */ 26 | ---@return string description JSDoc description, without /* * */ 27 | local function get_expected_from_jsdoc(comment) 28 | local lines = {} 29 | for line in comment:gmatch("([^\n]*)\n?") do 30 | local clean = line 31 | :gsub('^/%*%*', '') 32 | :gsub('%*/$', '') 33 | :gsub('%s+%* ?', '', 1) 34 | :gsub('@example EXPECTED%: ?', '') 35 | table.insert(lines, clean) 36 | end 37 | 38 | return trim(table.concat(lines, '\n')) 39 | end 40 | 41 | ---Retrieve all the cases in a fixture file. 42 | ---a case is a regexp expression with a JSDoc comment 43 | ---containing the expected regexplainer narrative result 44 | local function get_cases() 45 | local results = {} 46 | local parser = parsers.get_parser(0) 47 | local tree = parser:parse()[1] 48 | local next = {} 49 | for id, node in query:iter_captures(tree:root(), 0) do 50 | local name = query.captures[id] -- name of the capture in the query 51 | if name == 'test.comment' then 52 | local jsdoc_text = get_node_text(node, 0); 53 | next.expected = get_expected_from_jsdoc(jsdoc_text) 54 | elseif name == 'test.pattern' then 55 | next.pattern = get_node_text(node, 0) 56 | next.row = node:start() + 1 57 | end 58 | if next.row and next.expected and next.pattern then 59 | table.insert(results, next) 60 | next = {} 61 | end 62 | end 63 | return results 64 | end 65 | 66 | ---Cleanup any remaining buffers 67 | local function clear_buffers() 68 | for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do 69 | vim.api.nvim_buf_delete(bufnr, { force = true }) 70 | end 71 | end 72 | 73 | ---@param bufnr number 74 | ---@return string text buffer text 75 | local function get_buffer_text(bufnr) 76 | return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') 77 | end 78 | 79 | ---@param pattern string regexp pattern to test 80 | ---@return number bufnr bufnr of test fixture buffer 81 | local function setup_test_buffer(pattern) 82 | local newbuf = vim.api.nvim_create_buf(true, false) 83 | vim.api.nvim_win_set_buf(0, newbuf) 84 | vim.opt_local.filetype = 'javascript' 85 | vim.api.nvim_set_current_line('/'..pattern..'/;') 86 | vim.treesitter.start(newbuf, 'javascript') 87 | return newbuf 88 | end 89 | 90 | local function show_and_get_regexplainer_buffer(bufnr) 91 | local buffer 92 | repeat 93 | get_parser(0):parse() 94 | vim.uv.sleep(1) 95 | local row, col = unpack(vim.api.nvim_win_get_cursor(0)); 96 | vim.api.nvim_win_set_cursor(0, {row, col + 1}) 97 | local cursor_node = vim.treesitter.get_node() 98 | if (cursor_node) then 99 | for id, node in query:iter_captures(cursor_node, bufnr) do 100 | if query[id] == 'test.pattern' and node then 101 | local range = node:range() 102 | vim.api.nvim_win_set_cursor(0, { range[0], range[1] }) 103 | end 104 | end 105 | end 106 | regexplainer.show({debug = true}) 107 | buffer = buffers.get_last_buffer() 108 | until buffer 109 | return buffer.bufnr 110 | end 111 | 112 | local M = {} 113 | 114 | M.register_name = 'test' 115 | 116 | function M.iter_regexes_with_descriptions(filename) 117 | editfile(filename) 118 | local cases = get_cases() 119 | local index = 0 120 | return function() 121 | index = index + 1 122 | if index <= #cases then 123 | return cases[index] 124 | end 125 | end 126 | end 127 | 128 | function M.clear_test_state() 129 | vim.fn.setreg(M.register_name, '') 130 | regexplainer.teardown() -- Clear regexplainer state 131 | clear_buffers() 132 | assert(#vim.api.nvim_list_bufs() == 1, "Failed to properly clear buffers") 133 | assert(#vim.api.nvim_tabpage_list_wins(0) == 1, "Failed to properly clear tab") 134 | assert(vim.fn.getreg(M.register_name) == '', "Failed to properly clear register") 135 | end 136 | 137 | ---@param pattern string regexp pattern to test 138 | ---@param expected string expected markdown output 139 | ---@param message string test description 140 | function M.assert_string(pattern, expected, message) 141 | local newbufnr = setup_test_buffer(pattern) 142 | local rebufnr = show_and_get_regexplainer_buffer(newbufnr) 143 | local text = get_buffer_text(rebufnr) 144 | -- Cleanup any remaining buffers 145 | bd(newbufnr, { force = true }) 146 | regexplainer.hide() 147 | return assert.are.same(expected, text, message) 148 | end 149 | 150 | return M 151 | -------------------------------------------------------------------------------- /tests/lua/ansicolors.lua: -------------------------------------------------------------------------------- 1 | -- ansicolors.lua v1.0.2 (2012-08) 2 | 3 | -- Copyright (c) 2009 Rob Hoelz 4 | -- Copyright (c) 2011 Enrique García Cota 5 | -- 6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy 7 | -- of this software and associated documentation files (the "Software"), to deal 8 | -- in the Software without restriction, including without limitation the rights 9 | -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | -- copies of the Software, and to permit persons to whom the Software is 11 | -- furnished to do so, subject to the following conditions: 12 | -- 13 | -- The above copyright notice and this permission notice shall be included in 14 | -- all copies or substantial portions of the Software. 15 | -- 16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | -- THE SOFTWARE. 23 | 24 | 25 | -- support detection 26 | local function isWindows() 27 | return type(package) == 'table' and type(package.config) == 'string' and package.config:sub(1,1) == '\\' 28 | end 29 | 30 | local supported = not isWindows() 31 | if isWindows() then supported = os.getenv("ANSICON") end 32 | 33 | local keys = { 34 | -- reset 35 | reset = 0, 36 | 37 | -- misc 38 | bright = 1, 39 | dim = 2, 40 | underline = 4, 41 | blink = 5, 42 | reverse = 7, 43 | hidden = 8, 44 | 45 | -- foreground colors 46 | black = 30, 47 | red = 31, 48 | green = 32, 49 | yellow = 33, 50 | blue = 34, 51 | magenta = 35, 52 | cyan = 36, 53 | white = 37, 54 | 55 | -- background colors 56 | blackbg = 40, 57 | redbg = 41, 58 | greenbg = 42, 59 | yellowbg = 43, 60 | bluebg = 44, 61 | magentabg = 45, 62 | cyanbg = 46, 63 | whitebg = 47 64 | } 65 | 66 | local escapeString = string.char(27) .. '[%dm' 67 | local function escapeNumber(number) 68 | return escapeString:format(number) 69 | end 70 | 71 | local function escapeKeys(str) 72 | 73 | if not supported then return "" end 74 | 75 | local buffer = {} 76 | local number 77 | for word in str:gmatch("%w+") do 78 | number = keys[word] 79 | assert(number, "Unknown key: " .. word) 80 | table.insert(buffer, escapeNumber(number) ) 81 | end 82 | 83 | return table.concat(buffer) 84 | end 85 | 86 | local function replaceCodes(str) 87 | str = string.gsub(str,"(%%{(.-)})", function(_, str) return escapeKeys(str) end ) 88 | return str 89 | end 90 | 91 | -- public 92 | 93 | local function ansicolors( str ) 94 | str = tostring(str or '') 95 | 96 | return replaceCodes('%{reset}' .. str .. '%{reset}') 97 | end 98 | 99 | 100 | return setmetatable({noReset = replaceCodes}, {__call = function (_, str) return ansicolors (str) end}) 101 | -------------------------------------------------------------------------------- /tests/mininit.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.root(root) 4 | local f = debug.getinfo(1, "S").source:sub(2) 5 | return vim.fn.fnamemodify(f, ":p:h:h") .. "/" .. (root or "") 6 | end 7 | 8 | ---@param plugin string 9 | function M.load(plugin) 10 | local name = plugin:match(".*/(.*)") 11 | local package_root = M.root(".tests/site/pack/deps/start/") 12 | if not vim.loop.fs_stat(package_root .. name) then 13 | print("Installing " .. plugin) 14 | vim.fn.mkdir(package_root, "p") 15 | vim.fn.system({ 16 | "git", 17 | "clone", 18 | "--depth=1", 19 | "https://github.com/" .. plugin .. ".git", 20 | package_root .. "/" .. name, 21 | }) 22 | end 23 | end 24 | 25 | local langs = { 26 | 'html', 27 | 'javascript', 28 | 'typescript', 29 | 'regex', 30 | } 31 | 32 | function M.setup() 33 | vim.cmd([[ 34 | set noswapfile 35 | filetype on 36 | set runtimepath=$VIMRUNTIME 37 | runtime plugin/regexplainer.lua 38 | ]]) 39 | 40 | local parser_install_dir = M.root'.tests/share/treesitter'; 41 | vim.opt.runtimepath:append(parser_install_dir) 42 | vim.opt.runtimepath:append(M.root()) 43 | vim.opt.runtimepath:append(M.root'tests') 44 | vim.opt.packpath = { M.root'.tests/site' } 45 | 46 | M.load'MunifTanjim/nui.nvim' 47 | M.load'nvim-lua/plenary.nvim' 48 | M.load'nvim-treesitter/nvim-treesitter' 49 | 50 | 51 | vim.cmd[[packloadall]] 52 | 53 | require 'nvim-treesitter.configs'.setup { 54 | parser_install_dir = parser_install_dir, 55 | } 56 | 57 | for _, lang in ipairs(langs) do 58 | if not require'nvim-treesitter.parsers'.has_parser(lang) then 59 | vim.cmd('TSInstallSync ' .. lang) 60 | end 61 | end 62 | end 63 | 64 | M.setup() 65 | 66 | -------------------------------------------------------------------------------- /tests/queries/javascript/regexplainer_test.scm: -------------------------------------------------------------------------------- 1 | (comment) @test.comment 2 | (expression_statement 3 | (regex 4 | pattern: (regex_pattern) @test.pattern)) @test.expr 5 | -------------------------------------------------------------------------------- /tests/regexplainer/nvim-regexplainer_spec.lua: -------------------------------------------------------------------------------- 1 | local Utils = require 'tests.helpers.util' 2 | 3 | local regexplainer = require 'regexplainer' 4 | local scan = require 'plenary.scandir' 5 | 6 | local function file_filter(filename) 7 | local filter = vim.env.REGEXPLAINER_TEST_FILTER or nil 8 | if filter then 9 | return filename:match [[Sudoku]] 10 | else 11 | return #filename > 0 12 | end 13 | end 14 | 15 | local function row_filter(row) 16 | -- return row <= 12 17 | return true 18 | end 19 | 20 | local function category_filter(category) 21 | return (false 22 | or category == 'Simple Patterns' 23 | or category == 'Modifiers' 24 | or category == 'Ranges and Quantifiers' 25 | or category == 'Negated Ranges' 26 | or category == 'Capture Groups' 27 | or category == 'Named Capture Groups' 28 | or category == 'Non-Capturing Groups' 29 | or category == 'Alternations' 30 | or category == 'Lookaround' 31 | or category == 'Special Characters' 32 | or category == 'Practical Examples' 33 | or category == 'Regex Sudoku' 34 | or category == 'Errors' 35 | ) 36 | end 37 | 38 | describe("Regexplainer", function() 39 | describe('Yank', function() 40 | it('yanks into a given register', function() 41 | regexplainer.setup() 42 | local bufnr = vim.api.nvim_create_buf(true, true) 43 | 44 | local expected = "Either `hello` or `world`\n" 45 | local actual = 'FAIL' 46 | 47 | vim.api.nvim_buf_call(bufnr, function() 48 | vim.bo.filetype = 'javascript' 49 | vim.api.nvim_set_current_line[[/hello|world/;]] 50 | vim.cmd [[:norm l]] 51 | regexplainer.yank(Utils.register_name) 52 | actual = vim.fn.getreg(Utils.register_name) 53 | end) 54 | 55 | return assert.are.same(expected, actual, 'contents of a') 56 | end) 57 | end) 58 | before_each(Utils.clear_test_state) 59 | describe('Narratives', function() 60 | local all_files = scan.scan_dir('tests/fixtures/narrative', { depth = 1 }) 61 | local files = vim.tbl_filter(file_filter, all_files) 62 | for _, file in ipairs(files) do 63 | local category = file:gsub('tests/fixtures/narrative/%d+ (.*)%.js', '%1') 64 | if not category_filter(category) then 65 | print(require'ansicolors'('%{yellow}Skipping %{reset}') .. category) 66 | else 67 | describe(category, function() 68 | before_each(regexplainer.setup) 69 | for result in Utils.iter_regexes_with_descriptions(file) do 70 | if (row_filter(result.row)) then 71 | it('/'..result.pattern..'/', function() 72 | Utils.assert_string(result.pattern, result.expected, file .. ':' .. result.row) 73 | end) 74 | end 75 | end 76 | end) 77 | end 78 | end 79 | end) 80 | end) 81 | --------------------------------------------------------------------------------