diff --git a/Makefile b/Makefile index c64250d..0c43203 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ watch: -type f \ -name '*.lua' \ -o -name '*.js' \ - ! -path "./.tests/**/*" | entr -d make run_tests + ! -path "./.tests/**/*" | entr -d make test test: @REGEXPLAINER_DEBOUNCE=false \ @@ -18,5 +18,5 @@ test: --headless \ --noplugin \ -u tests/mininit.lua \ - -c "lua require'plenary.test_harness'.test_directory('tests/regexplainer/', {minimal_init='tests/mininit.lua',sequential=true})"\ + -c "PlenaryBustedDirectory tests/regexplainer/ {minimal_init='tests/mininit.lua',sequential=true,keep_going=false}"\ -c "qa!" diff --git a/README.md b/README.md index 0633ac1..d5fbf31 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ require'regexplainer'.setup { }, -- Whether to log debug messages - debug = false, + debug = false, -- 'split', 'popup' display = 'popup', @@ -73,7 +73,7 @@ require'regexplainer'.setup { }, narrative = { - separator = '\n', + indendation_string = '> ', -- default ' ' }, } ``` @@ -122,21 +122,13 @@ your editor. ### Render Options -`narrative.separator` can also be a function taking the current component and -returning a string clause separator. For example, to separate clauses by a new -line, followed by `> ` for each level of capture-group depth, define the -following function: +`narrative.indendation_string` can be a function taking the current component and +returning an indendation indicator string. For example, to show the capture group on each line: ```lua narrative = { - separator = function(component) - local sep = '\n'; - if component.depth > 0 then - for _ = 1, component.depth do - sep = sep .. '> ' - end - end - return sep + indentation_string = function(component) + return component.capture_depth .. '> ' end }, ``` @@ -144,19 +136,19 @@ narrative = { Input: ```javascript -/zero(one(two(?three)))/; +/zero(one(two(three)))/; ``` Output: ```markdown -`zero` -capture group 1: -> `one` -> capture group 2: -> > `two` -> > named capture group 3 `inner`: -> > > `three` +`zero` +capture group 1: +1> `one` +1> capture group 2: +1> 2> `two` +1> 2> capture group 3: +1> 2> 3> `three` ``` ## Yank diff --git a/lua/regexplainer.lua b/lua/regexplainer.lua index 6ead6e5..1918445 100644 --- a/lua/regexplainer.lua +++ b/lua/regexplainer.lua @@ -1,11 +1,17 @@ local component = require 'regexplainer.component' local tree = require 'regexplainer.utils.treesitter' local utils = require 'regexplainer.utils' -local buffers = require 'regexplainer.buffers' -local defer = require 'regexplainer.utils.defer' +local Buffers = require 'regexplainer.buffers' + +local get_node_text = vim.treesitter.get_node_text +local deep_extend = vim.tbl_deep_extend +local map = vim.tbl_map +local buf_delete = vim.api.nvim_buf_delete +local ag = vim.api.nvim_create_augroup +local au = vim.api.nvim_create_autocmd ---@class RegexplainerOptions ----@field mode? 'narrative' # TODO: 'ascii', 'graphical' +---@field mode? 'narrative'|'debug' # TODO: 'ascii', 'graphical' ---@field auto? boolean # Automatically display when cursor enters a regexp ---@field filetypes? string[] # Filetypes (extensions) to automatically show regexplainer. ---@field debug? boolean # Notify debug logs @@ -15,10 +21,10 @@ local defer = require 'regexplainer.utils.defer' ---@field popup? NuiPopupBufferOptions # options for the popup buffer ---@field split? NuiSplitBufferOptions # options for the split buffer ----@class RegexplainerRenderOptions : RegexplainerOptions +---@class RegexplainerRenderOptions: RegexplainerOptions ---@field register "*"|"+"|'"'|":"|"."|"%"|"/"|"#"|"0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9" ----@class RegexplainerYankOptions : RegexplainerOptions +---@class RegexplainerYankOptions: RegexplainerOptions ---@field register "*"|"+"|'"'|":"|"."|"%"|"/"|"#"|"0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9" ---@class RegexplainerMappings @@ -29,15 +35,12 @@ local defer = require 'regexplainer.utils.defer' ---@field show_split? string # shows regexplainer in a split window ---@field show_popup? string # shows regexplainer in a popup window -local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_node_text - ---Maps config.mappings keys to vim command names and descriptions -- local config_command_map = { show = { 'RegexplainerShow', 'Show Regexplainer' }, hide = { 'RegexplainerHide', 'Hide Regexplainer' }, - toggle = { 'RegexplainerToggle', 'Toggle Regexplainer' }, - yank = { 'RegexplainerYank', 'Yank Regexplainer' }, + toggle = { 'RegexplainerToggle', 'Toggle Regexplainer' }, yank = { 'RegexplainerYank', 'Yank Regexplainer' }, show_split = { 'RegexplainerShowSplit', 'Show Regexplainer in a split Window' }, show_popup = { 'RegexplainerShowPopup', 'Show Regexplainer in a popup' }, } @@ -51,14 +54,10 @@ local default_config = { auto = false, filetypes = { 'html', - 'js', - 'cjs', - 'mjs', - 'ts', - 'jsx', - 'tsx', - 'cjsx', - 'mjsx', + 'js', 'javascript', 'cjs', 'mjs', + 'ts', 'typescript', 'cts', 'mts', + 'tsx', 'typescriptreact', 'ctsx', 'mtsx', + 'jsx', 'javascriptreact', 'cjsx', 'mjsx', }, debug = false, display = 'popup', @@ -66,7 +65,7 @@ local default_config = { toggle = 'gR', }, narrative = { - separator = '\n', + indentation_string = ' ', }, } @@ -74,67 +73,54 @@ local default_config = { --- During setup(), any user-provided config will be folded in ---@type RegexplainerOptions -- -local local_config = vim.tbl_deep_extend('keep', default_config, {}) +local local_config = deep_extend('keep', default_config, {}) --- Show the explainer for the regexp under the cursor ---@param options? RegexplainerOptions overrides for this call ----@return nil +---@return nil|number bufnr the bufnr of the regexplaination -- -local function show(options) - options = vim.tbl_deep_extend('force', local_config, options or {}) - local node, error = tree.get_regexp_pattern_at_cursor(options) +local function show_for_real(options) + options = deep_extend('force', local_config, options or {}) + local node, scratchnr, error = tree.get_regexp_pattern_at_cursor() if error and options.debug then utils.notify('Rexexplainer: ' .. error, 'debug') - elseif node then - -- in the case of a pattern node, we need to get the first child 🤷 - if node:type() == 'pattern' and node:child_count() == 1 then - node = node:child(0) - end - - ---@type RegexplainerRenderer - local renderer + elseif node and scratchnr then ---@type boolean, RegexplainerRenderer - local can_render, _renderer = pcall(require, 'regexplainer.renderers.' .. options.mode) + local can_render, renderer = pcall(require, 'regexplainer.renderers.' .. options.mode) - if can_render then - renderer = _renderer - else + if not can_render then utils.notify(options.mode .. ' is not a valid renderer', 'warning') utils.notify(renderer, 'error') renderer = require 'regexplainer.renderers.narrative' end - local components = component.make_components(node, nil, node) + local components = component.make_components(scratchnr, node, nil, node) - local buffer = buffers.get_buffer(options) + local buffer = Buffers.get_buffer(options) if not buffer and options.debug then - local Debug = require 'regexplainer.renderers.debug' - return Debug.render(options, components) + renderer = require'regexplainer.renderers.debug' end - buffers.render(buffer, renderer, components, options, { - full_regexp_text = get_node_text(node, 0), - }) + local state = { full_regexp_text = get_node_text(node, scratchnr) } + + Buffers.render(buffer, renderer, components, options, state) + buf_delete(scratchnr, { force = true }) else - buffers.hide_all() + Buffers.hide_all() end end local disable_auto = false -local show_debounced_trailing, timer_trailing = defer.debounce_trailing(show, 5) - -buffers.register_timer(timer_trailing) - local M = {} --- Show the explainer for the regexp under the cursor ---@param options? RegexplainerOptions function M.show(options) disable_auto = true - show(options) + show_for_real(options) disable_auto = false end @@ -145,11 +131,7 @@ function M.yank(options) if type(options) == 'string' then options = { register = options } end - show(vim.tbl_deep_extend( - 'force', - options, - { display = 'register' } - )) + show_for_real(deep_extend('force', options, { display = 'register' })) disable_auto = false end @@ -158,16 +140,16 @@ end ---@return nil -- function M.setup(config) - local_config = vim.tbl_deep_extend('keep', config or {}, default_config) + local_config = deep_extend('keep', config or {}, default_config) -- bind keys from config local has_which_key = pcall(require, 'which-key') - for cmd, binding in pairs(local_config.mappings) do - local command = ':' .. config_command_map[cmd][1] .. '' + for cmdmap, binding in pairs(local_config.mappings) do + local cmd, description = unpack(config_command_map[cmdmap]) + local command = ':' .. cmd .. '' if has_which_key then local wk = require 'which-key' - local description = config_command_map[cmd][2] wk.register({ [binding] = { command, description } }, { mode = 'n' }) else utils.map('n', binding, command) @@ -176,13 +158,13 @@ function M.setup(config) -- setup autocommand if local_config.auto then - vim.api.nvim_create_augroup(augroup_name, { clear = true }) - vim.api.nvim_create_autocmd('CursorMoved', { + ag(augroup_name, { clear = true }) + au('CursorMoved', { group = 'Regexplainer', - pattern = vim.tbl_map(function(x) return '*.' .. x end, local_config.filetypes), + pattern = map(function(x) return '*.' .. x end, local_config.filetypes), callback = function() - if not disable_auto then - show_debounced_trailing() + if tree.has_regexp_at_cursor() and not disable_auto then + show_for_real() end end, }) @@ -194,13 +176,13 @@ end --- Hide any displayed regexplainer buffers -- function M.hide() - buffers.hide_all() + Buffers.hide_all() end --- Toggle Regexplainer -- function M.toggle() - if buffers.is_open() then + if Buffers.is_open() then M.hide() else M.show() @@ -211,7 +193,7 @@ end -- function M.teardown() local_config = vim.tbl_deep_extend('keep', {}, default_config) - buffers.clear_timers() + Buffers.clear_timers() pcall(vim.api.nvim_del_augroup_by_name, augroup_name) end @@ -220,7 +202,7 @@ end function M.debug_components() ---@type any local mode = 'debug' - show({ auto = false, display = 'split', mode = mode }) + show_for_real({ auto = false, display = 'split', mode = mode }) end return M diff --git a/lua/regexplainer/buffers/init.lua b/lua/regexplainer/buffers/init.lua index 6f09fce..10f9d44 100644 --- a/lua/regexplainer/buffers/init.lua +++ b/lua/regexplainer/buffers/init.lua @@ -1,14 +1,9 @@ local utils = require 'regexplainer.utils' -local all_buffers = {} +local get_current_win = vim.api.nvim_get_current_win +local get_current_buf = vim.api.nvim_get_current_buf --- TODO: remove this, or only store the last parent, --- push and pop from all_buffers instead -local last = { - parent = nil, - split = nil, - popup = nil, -} +local all_buffers = {} ---@param object RegexplainerBuffer ---@returns 'NuiSplit'|'NuiPopup'|'Scratch' @@ -42,6 +37,18 @@ local function close_timers() end end +---@param options RegexplainerOptions +---@return RegexplainerBuffer +local function get_buffer(options, state) + if options.display == 'register' then + return require'regexplainer.buffers.register'.get_buffer(options, state) + elseif options.display == 'split' then + return require'regexplainer.buffers.split'.get_buffer(options, state) + else --if options.display == 'popup' then + return require'regexplainer.buffers.popup'.get_buffer(options, state) + end +end + -- Functions to create or modify the buffer which displays the regexplanation -- local M = {} @@ -53,27 +60,17 @@ local M = {} function M.get_buffer(options) options = options or {} - local buffer - local state = { - last = last + last = M.get_last_buffer() or {}, } - if options.display == 'register' then - buffer = require'regexplainer.buffers.register'.get_buffer(options, state) - - elseif options.display == 'split' then - buffer = require'regexplainer.buffers.split'.get_buffer(options, state) - - elseif options.display == 'popup' then - buffer = require'regexplainer.buffers.popup'.get_buffer(options, state) - end + local buffer = get_buffer(options, state) table.insert(all_buffers, buffer); state.last.parent = { - winnr = vim.api.nvim_get_current_win(), - bufnr = vim.api.nvim_get_current_buf(), + winnr = get_current_win(), + bufnr = get_current_buf(), } return buffer @@ -86,7 +83,7 @@ end ---@param state RegexplainerRendererState -- function M.render(buffer, renderer, components, options, state) - state.last = last + state.last = state.last or M.get_last_buffer() local lines = renderer.get_lines(components, options, state) buffer:init(lines, options, state) renderer.set_lines(buffer, lines) @@ -105,19 +102,13 @@ function M.kill_buffer(buffer) table.remove(all_buffers, i) end end - for _, key in ipairs({ 'popup', 'split' }) do - if last[key] == buffer then - last[key] = nil - end - end end end --- Hide the last-opened Regexplainer buffer -- function M.hide_last() - M.kill_buffer(last.popup) - M.kill_buffer(last.split) + M.kill_buffer(M.get_last_buffer()) end --- Hide all known Regexplainer buffers @@ -126,8 +117,6 @@ function M.hide_all() for _, buffer in ipairs(all_buffers) do M.kill_buffer(buffer) end - last.split = nil - last.popup = nil end --- Notify regarding all known Regexplainer buffers @@ -136,17 +125,24 @@ end -- function M.debug_buffers() utils.notify(all_buffers) - utils.notify(last) end --- get all active regexplaine buffers --- **INTERNAL**: for debug purposes only ---@private -- -function M.get_buffers() +function M.get_all_buffers() return all_buffers end +--- get last active regexplainer buffer +--- **INTERNAL**: for debug purposes only +---@private +-- +function M.get_last_buffer() + return all_buffers[#all_buffers] +end + --- Whether there are any open Regexplainer buffers ---@return boolean -- diff --git a/lua/regexplainer/buffers/popup.lua b/lua/regexplainer/buffers/popup.lua index 49b8458..864d7b4 100644 --- a/lua/regexplainer/buffers/popup.lua +++ b/lua/regexplainer/buffers/popup.lua @@ -2,6 +2,10 @@ local Shared = require 'regexplainer.buffers.shared' local M = {} +local au = vim.api.nvim_create_autocmd +local get_win_width = vim.api.nvim_win_get_width +local extend = vim.tbl_deep_extend + ---@type NuiPopupBufferOptions local popup_defaults = { position = 2, @@ -16,7 +20,7 @@ local popup_defaults = { local function init(self, lines, _, state) Shared.default_buffer_init(self) - local win_width = vim.api.nvim_win_get_width(state.last.parent.winnr) + local win_width = get_win_width(state.last.parent.winnr) ---@type number|string local width = 0 @@ -36,32 +40,24 @@ end local function after(self, _, options, state) if options.auto then - local function unmount() self:unmount() end - local bufnr = state.last.parent.bufnr - vim.api.nvim_create_autocmd('BufLeave', { buffer = bufnr, once = true, callback = unmount }) - self:on({ 'BufLeave', 'BufWinLeave', 'CursorMoved' }, unmount, { once = true }) + au({ 'BufLeave', 'BufWinLeave', 'CursorMoved' }, { + buffer = state.last.parent.bufnr, + once = true, + callback = function() self:unmount() end, + }) end end function M.get_buffer(options, state) - if state.last.popup then - return state.last.popup - end - - local Popup = require 'nui.popup' - - local buffer = Popup(vim.tbl_deep_extend('force', + local Popup = require'nui.popup' + local buffer = Popup(extend('force', Shared.shared_options, popup_defaults, options.popup or {} ) or popup_defaults) - buffer.type = 'NuiPopup' - - state.last.popup = buffer - + state.last = buffer buffer.init = init buffer.after = after - return buffer end diff --git a/lua/regexplainer/buffers/split.lua b/lua/regexplainer/buffers/split.lua index d8f61d2..b22939b 100644 --- a/lua/regexplainer/buffers/split.lua +++ b/lua/regexplainer/buffers/split.lua @@ -1,6 +1,11 @@ local Shared = require 'regexplainer.buffers.shared' local Split = require 'nui.split' +local set_current_win = vim.api.nvim_set_current_win +local set_current_buf = vim.api.nvim_set_current_buf +local win_set_height = vim.api.nvim_win_set_height +local extend = vim.tbl_deep_extend + local M = {} ---@type NuiSplitBufferOptions @@ -11,29 +16,20 @@ local split_defaults = { } local function after(self, lines, _, state) - vim.api.nvim_set_current_win(state.last.parent.winnr) - vim.api.nvim_set_current_buf(state.last.parent.bufnr) - vim.api.nvim_win_set_height(self.winid, #lines) + set_current_win(state.last.parent.winnr) + set_current_buf(state.last.parent.bufnr) + win_set_height(self.winid, #lines) end function M.get_buffer(options, state) - if state.last.split then - return state.last.split + if state.last.type == 'NuiSplit' then + return state.last end - - local buffer = Split(vim.tbl_deep_extend('force', - Shared.shared_options, - split_defaults, - options.split or {})) - + local buffer = Split(extend('force', Shared.shared_options, split_defaults, options.split or {})) buffer.type = 'NuiSplit' - - state.last.split = buffer - + state.last = buffer buffer.init = Shared.default_buffer_init buffer.after = after - - vim.notify('split buf') return buffer end diff --git a/lua/regexplainer/component/descriptions.lua b/lua/regexplainer/component/descriptions.lua index c5908d6..c2f4c26 100644 --- a/lua/regexplainer/component/descriptions.lua +++ b/lua/regexplainer/component/descriptions.lua @@ -1,6 +1,6 @@ -local utils = require 'regexplainer.utils' -local component_pred = require 'regexplainer.component' -local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_node_text +local utils = require 'regexplainer.utils' +local Predicates = require 'regexplainer.component.predicates' +local get_node_text = vim.treesitter.get_node_text local M = {} @@ -8,23 +8,23 @@ local M = {} ---@param quantifier_node TreesitterNode ---@return string -- -function M.describe_quantifier(quantifier_node) +function M.describe_quantifier(quantifier_node, bufnr) -- TODO: there's probably a better way to do this - local text = get_node_text(quantifier_node, 0) + local text = get_node_text(quantifier_node, bufnr) if text:match ',' then local matches = {} for match in text:gmatch '%d+' do table.insert(matches, match) end - local min = matches[1] - local max = matches[2] + local min, max = unpack(matches) if max then return min .. '-' .. max .. 'x' else return '>= ' .. min .. 'x' end else - return text:match '%d+' .. 'x' + local match = text:match '%d+' + return match .. 'x' end end @@ -40,9 +40,9 @@ function M.describe_character_class(component) local initial_sep = i == 1 and '' or ', ' local text = utils.escape_markdown(child.text) - if component_pred.is_identity_escape(child) then + if Predicates.is_identity_escape(child) then text = '`' .. text:sub(-1) .. '`' - elseif component_pred.is_escape(child) then + elseif Predicates.is_escape(child) then text = '**' .. M.describe_escape(text) .. '**' else text = '`' .. text .. '`' @@ -57,7 +57,7 @@ end ---@return string function M.describe_escape(escape) local char = escape:gsub([[\\]], [[\]]):sub(2) - if char == 'd' then return '0-9' + if char == 'd' then return '0-9' elseif char == 'n' then return 'LF' elseif char == 'r' then return 'CR' elseif char == 's' then return 'WS' diff --git a/lua/regexplainer/component/init.lua b/lua/regexplainer/component/init.lua index 2fad093..f3b4287 100644 --- a/lua/regexplainer/component/init.lua +++ b/lua/regexplainer/component/init.lua @@ -1,14 +1,18 @@ local node_pred = require 'regexplainer.utils.treesitter' +local Predicates = require'regexplainer.component.predicates' +local Utils = require 'regexplainer.utils' ---@diagnostic disable-next-line: unused-local local log = require 'regexplainer.utils'.debug -local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_node_text +local get_node_text = vim.treesitter.get_node_text +local extend = vim.tbl_extend +local deep_extend = vim.tbl_deep_extend ---@class RegexplainerBaseComponent ---@field type RegexplainerComponentType # Which type of component ---@field text string # full text of this regexp component ----@field depth number # how many levels deep is this component, where 0 is top-level. +---@field capture_depth number # how many levels deep is this component, where 0 is top-level. ---@field quantifier? string # a quantified regexp component ---@field optional? boolean # a regexp component marked with `?` ---@field zero_or_more? boolean # a regexp component marked with `*` @@ -19,14 +23,15 @@ local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_n ---@field error? any # parsing error ---@class RegexplainerParentComponent : RegexplainerBaseComponent ----@field children? RegexplainerComponent # Components may contain other components, e.g. capture groups +---@field children? (RegexplainerComponent)[] # Components may contain other components, e.g. capture groups ---@class RegexplainerCaptureGroupComponent : RegexplainerParentComponent ----@field group_name? boolean # a regexp component marked with `+` +---@field group_name? string # the name of the capture group, if it's a named group ---@field capture_group? number # which capture group does this group represent? ---@alias RegexplainerComponentType ---| 'alternation' +---| 'start_assertion' ---| 'boundary_assertion' ---| 'character_class' ---| 'character_class_escape' @@ -38,6 +43,7 @@ local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_n ---| 'pattern' ---| 'pattern_character' ---| 'term' +---| 'root' ---@alias RegexplainerComponent ---| RegexplainerBaseComponent @@ -45,148 +51,11 @@ local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_n local M = {} ----@type RegexplainerComponentType[] -local component_types = { - 'alternation', - 'boundary_assertion', - 'character_class', - 'character_class_escape', - 'class_range', - 'control_escape', - 'decimal_escape', - 'identity_escape', - 'lookaround_assertion', - 'pattern', - 'pattern_character', - 'term', -} - --- Keys which all components share, regardless. --- The absence of keys other than these implies that the component is simple --- -local common_keys = { - 'type', - 'text', - 'depth', -} - -- keep track of how many captures we've seen -- make sure to unset when finished an entire regexp -- local capture_tally = 0 -local lookuptables = {} -setmetatable(lookuptables, { __mode = "v" }) -- make values weak -local function get_lookup(xs) - local key = type(xs) == 'string' and xs or table.concat(xs, '-') - if lookuptables[key] then return lookuptables[key] - else - local lookup = {} - for _, v in ipairs(xs) do lookup[v] = true end - lookuptables[key] = lookup - return lookup - end -end - ---- Memoized `elem` predicate ----@generic T ----@param x T needle ----@param xs T[] haystack --- -local function elem(x, xs) - return get_lookup(xs)[x] or false -end - -for _, type in ipairs(component_types) do - M['is_' .. type] = function(component) - return component.type == type - end -end - ----@param component RegexplainerComponent ----@return boolean --- -function M.is_escape(component) - return component.type == 'boundary_assertion' or component.type:match 'escape$' -end - ----@param component RegexplainerComponent ----@return boolean --- -function M.is_lookaround_assertion(component) - return component.type:find('^lookaround_assertion') ~= nil -end - ----@param component RegexplainerComponent ----@return boolean --- -function M.is_lookbehind_assertion(component) - return component.type:find('^lookaround_assertion') ~= nil -end - - --- Does a container component contain nothing by pattern_characters? ----@param component RegexplainerComponent ----@return boolean --- -function M.is_only_chars(component) - if component.children then - for _, child in ipairs(component.children) do - if child.type ~= 'pattern_character' then - return false - end - end - end - return true -end - ----@param component RegexplainerComponent ----@return boolean --- -function M.is_capture_group(component) - local found = component.type:find('capturing_group$') - return found ~= nil -end - -function M.is_simple_component(component) - local has_extras = false - - for _, key in ipairs(vim.tbl_keys(component)) do - if not has_extras then - has_extras = not elem(key, common_keys) - end - end - - return not has_extras -end - ---- A 'simple' component contains no children or modifiers. ---- Used e.g. to concatenate successive unmodified pattern_characters ----@param component RegexplainerComponent ----@return boolean --- -function M.is_simple_pattern_character(component) - if not component or M.is_special_character(component) then - return false - end - - if M.is_identity_escape(component) - or M.is_decimal_escape(component) - or component.type ~= 'pattern_character' then - return M.is_simple_component(component) - end - - return M.is_simple_component(component) -end - ----@param component RegexplainerComponent ----@return boolean --- -function M.is_special_character(component) - return not not (component.type:find 'assertion$' - or component.type:find 'character$' - and component.type ~= 'pattern_character') -end ---@param node TreesitterNode local function has_lazy(node) @@ -201,22 +70,33 @@ end ---@alias TreesitterNode any --- Transform a treesitter node to a table of components which are easily rendered +---@param bufnr number ---@param node TSNode ----@param parent? TSNode +---@param parent? RegexplainerComponent ---@param root_regex_node TSNode ---@return RegexplainerComponent[] -- -function M.make_components(node, parent, root_regex_node) - local text = get_node_text(node, 0) - local cached = lookuptables[text] +function M.make_components(bufnr, node, parent, root_regex_node) + local text = get_node_text(node, bufnr) + local cached = Utils.get_cached(text) + local parent_depth = parent and parent.capture_depth or 0 + if cached then return cached end local components = {} local node_type = node:type() + ---@return RegexplainerComponent component + local function c(component) + return extend('force', { + type = 'root', + capture_depth = parent_depth, + }, component) + end + if node_type == 'alternation' and node == root_regex_node then - table.insert(components, { + table.insert(components, c { type = node_type, text = text, children = {}, @@ -226,7 +106,7 @@ function M.make_components(node, parent, root_regex_node) for child in node:iter_children() do local type = child:type() - local child_text = get_node_text(child, 0) + local child_text = get_node_text(child, bufnr) local previous = components[#components] @@ -235,21 +115,23 @@ function M.make_components(node, parent, root_regex_node) previous.text = previous.text:gsub([[^\+]], '') end - if M.is_simple_pattern_character(previous) and #previous.text > 1 then + if Predicates.is_simple_pattern_character(previous) and #previous.text > 1 then local last_char = previous.text:sub(-1) - - if M.is_identity_escape(previous) - and M.is_simple_component(previous) then + if Predicates.is_identity_escape(previous) + and Predicates.is_simple_component(previous) then previous.text = previous.text .. last_char - elseif not M.is_control_escape(previous) - and not M.is_character_class_escape(previous) then + elseif not Predicates.is_control_escape(previous) + and not Predicates.is_character_class_escape(previous) then previous.text = previous.text:sub(1, -2) - table.insert(components, { type = 'pattern_character', text = last_char }) + table.insert(components, c { + type = 'pattern_character', + text = last_char, + }) previous = components[#components] end end - components[#components] = vim.tbl_deep_extend('force', previous, props) + components[#components] = deep_extend('force', previous, props) return components[#components] end @@ -263,7 +145,7 @@ function M.make_components(node, parent, root_regex_node) } elseif type == 'count_quantifier' then append_previous { - quantifier = require 'regexplainer.component.descriptions'.describe_quantifier(child), + quantifier = require 'regexplainer.component.descriptions'.describe_quantifier(child, bufnr), lazy = has_lazy(child), } @@ -271,7 +153,7 @@ function M.make_components(node, parent, root_regex_node) -- pattern characters and simple escapes can be collapsed together -- so long as they are not immediately followed by a modifier elseif type == 'pattern_character' - and M.is_simple_pattern_character(previous) then + and Predicates.is_simple_pattern_character(previous) then if previous.type == 'identity_escape' then previous.text = previous.text:gsub([[^\+]], '') end @@ -279,82 +161,60 @@ function M.make_components(node, parent, root_regex_node) previous.text = previous.text .. child_text previous.type = 'pattern_character' elseif (type == 'identity_escape' or type == 'decimal_escape') - and M.is_simple_pattern_character(previous) then + and Predicates.is_simple_pattern_character(previous) then if node_type ~= 'character_class' and not node_pred.is_modifier(child:next_sibling()) then previous.text = previous.text .. child_text:gsub([[^\+]], '') else - table.insert(components, { + table.insert(components, c { type = type, text = child_text }) end elseif type == 'start_assertion' then - table.insert(components, { type = type, text = '^' }) + table.insert(components, c { type = type, text = '^' }) -- handle errors - -- treesitter does not appear to support js lookbehinds - -- see https://github.com/tree-sitter/tree-sitter-javascript/issues/214 -- elseif type == 'ERROR' then - local error_text = get_node_text(child, 0) + local error_text = get_node_text(child, bufnr) local row, e_start, _, e_end = child:range() local _, re_start = node:range() - - -- TODO: until treesitter supports lookbehind, we can parse it ourselves - -- This code, however, is not ready to use - - local from_re_start_to_err_start = e_start - re_start + 1 - - local error_term_text = text:sub(from_re_start_to_err_start) - - local lookbehind = error_term_text:match [[(%(%? local M = {} diff --git a/lua/regexplainer/renderers/narrative/init.lua b/lua/regexplainer/renderers/narrative/init.lua index bc73dba..7ea7928 100644 --- a/lua/regexplainer/renderers/narrative/init.lua +++ b/lua/regexplainer/renderers/narrative/init.lua @@ -5,27 +5,11 @@ local buffers = require 'regexplainer.buffers' -- local M = {} -local function check_for_lookbehind(components) - for _, component in ipairs(components) do - if component.type == 'lookbehind_assertion' or check_for_lookbehind(component.children or {}) then - return true - end - end - return false -end - ---@param components RegexplainerComponent[] ---@param options RegexplainerOptions ----@param state RegexplainerRendererState +---@param state RegexplainerNarrativeRendererState function M.get_lines(components, options, state) local lines = narrative.recurse(components, options, state) - local found = check_for_lookbehind(components) - if found then - table.insert(lines, 1, '⚠️ **Lookbehinds are poorly supported**') - table.insert(lines, 2, '⚠️ results may not be accurate') - table.insert(lines, 3, '⚠️ See https://github.com/tree-sitter/tree-sitter-regex/issues/13') - table.insert(lines, 4, '') - end return lines end diff --git a/lua/regexplainer/renderers/narrative/narrative.lua b/lua/regexplainer/renderers/narrative/narrative.lua index 01cd4e5..e565142 100644 --- a/lua/regexplainer/renderers/narrative/narrative.lua +++ b/lua/regexplainer/renderers/narrative/narrative.lua @@ -1,281 +1,315 @@ -local descriptions = require 'regexplainer.component.descriptions' -local comp = require 'regexplainer.component' -local utils = require 'regexplainer.utils' - ----@diagnostic disable-next-line: unused-local -local log = require 'regexplainer.utils'.debug +local D = require'regexplainer.component.descriptions' +local P = require'regexplainer.component.predicates' +local U = require'regexplainer.utils' +local extend = function(a, b) return vim.tbl_extend('force', a, b) end local M = {} ---@class RegexplainerNarrativeRendererOptions ----@field separator string|fun(component:Component):string # clause separator +---@field indentation_string string|fun(component:RegexplainerComponent):string # clause separator ---@class RegexplainerNarrativeRendererState : RegexplainerRendererState ----@field depth number # tracker for component depth ----@field lookbehind_found boolean # see https://github.com/tree-sitter/tree-sitter-regex/issues/13 ---@field first boolean # is it first in the term? ---@field last boolean # is it last in the term? +---@field parent RegexplainerComponent the parent component ---- Get a suffix describing the component's quantifier, optionality, etc +--- Get a description of the component's quantifier, optionality, etc ---@param component RegexplainerComponent ---@return string -- -local function get_suffix(component) - local suffix = '' +local function get_quantifier(component) + local quant = '' if component.quantifier then - suffix = ' (_' .. component.quantifier .. '_)' + quant = ' (_' .. component.quantifier .. '_)' end if component.optional then - suffix = suffix .. ' (_optional_)' + quant = quant .. ' (_optional_)' elseif component.zero_or_more then - suffix = suffix .. ' (_>= 0x_)' + quant = quant .. ' (_>= 0x_)' elseif component.one_or_more then - suffix = suffix .. ' (_>= 1x_)' + quant = quant .. ' (_>= 1x_)' end if component.lazy then - suffix = suffix .. ' (_lazy_)' - end - - return suffix -end - ---- Get a title for a group component ----@param component RegexplainerComponent ----@return string --- -local function get_group_heading(component) - local name = component.group_name and ('`' .. component.group_name .. '`') or '' - local header - if component.type == 'named_capturing_group' then - header = 'named capture group ' .. component.capture_group .. ' ' .. name - elseif component.type == 'non_capturing_group' then - header = 'non-capturing group ' - else - header = 'capture group ' .. component.capture_group + quant = quant .. ' (_lazy_)' end - return header:gsub(' $', '') + return quant end ----@param orig_sep string # the original configured separator string ---@param component RegexplainerComponent # component to render +---@param options RegexplainerOptions # the original configured separator string ---@return string # the next separator string -local function default_sep(orig_sep, component) - local sep = orig_sep; - if component.depth > 0 then - - for _ = 1, component.depth do - sep = sep .. ' ' - end +local function get_indent_string(component, options) + local indent = options.narrative.indentation_string + if component.type == 'pattern' or component.type == 'term' then + return '' + elseif type(indent) == "function" then + return indent(component) + else + return indent end - return sep end ---- Get all lines for a recursive component's children +--- Get a description of the component's quantifier, optionality, etc ---@param component RegexplainerComponent ---@param options RegexplainerOptions ---@param state RegexplainerNarrativeRendererState ----@return string[], string +---@return string -- -local function get_sublines(component, options, state) - local sep = options.narrative.separator +local function get_prefix(component, options, state) + local prefix = '' - if type(options.narrative.separator) == "function" then - sep = options.narrative.separator(component) - else - sep = default_sep(sep or '\n', component) + if state.first and not state.last then + prefix = '' + elseif state.last and not state.first then + prefix = '' end - local children = component.children - while (#children == 1 and (comp.is_term(children[1]) - or comp.is_pattern(children[1]))) do - children = children[1].children + if P.is_alternation(component) then + prefix = 'Either ' end - return M.recurse(children, options, vim.tbl_deep_extend('force', state, { - depth = (state.depth or 0) + 1, - })), sep + if component.optional or component.quantifier then + prefix = prefix .. '\n' + end + + return prefix end ---- Get a narrative clause for a component and all it's children ---- i.e. a single top-level narrative unit +--- Get a suffix for the current clause ---@param component RegexplainerComponent ---@param options RegexplainerOptions ---@param state RegexplainerNarrativeRendererState ---@return string -- -local function get_narrative_clause(component, options, state) - local prefix = '' - local infix = '' +local function get_suffix(component, options, state) local suffix = '' + if not P.is_capture_group(component) and not P.is_lookaround_assertion(component) then + suffix = get_quantifier(component) + end + if component.zero_or_more or component.quantifier or component.one_or_more then + suffix = suffix .. '\n' + end + return suffix +end - if state.first and not state.last then - prefix = '' - elseif not state.depth and state.last and not state.first then - prefix = '' +---@param component RegexplainerComponent +---@param options RegexplainerOptions +---@param state RegexplainerNarrativeRendererState +---@return string +-- +local function get_infix(component, options, state) + if P.is_term(component) or P.is_pattern(component) then + if P.is_only_chars(component) then + return '`' .. component.text .. '`' + else + local sep = get_indent_string(component, options) + local line_sep = P.is_alternation(state.parent) and '' or '\n' + local sublines = M.recurse(component.children, options, state) + local contents = table.concat(sublines, '\n') + return '' + .. get_quantifier(component) + .. line_sep + .. string.rep(sep, component.capture_depth) + .. line_sep + .. contents + .. line_sep + end end - if comp.is_alternation(component) then + if P.is_alternation(component) then + -- we have to do alternations by iterating instead of recursing + local infix = '' for i, child in ipairs(component.children) do local oxford = i == #component.children and 'or ' or '' local first_in_alt = i == 1 local last_in_alt = i == #component.children - prefix = 'Either ' + local next_state = extend(state, { + first = first_in_alt, + last = last_in_alt, + parent = component + }) infix = infix .. (first_in_alt and '' or #component.children == 2 and ' ' or ', ') .. oxford - .. get_narrative_clause(child, options, vim.tbl_extend('force', state, { - first = first_in_alt, - last = last_in_alt, - })) + .. get_prefix(child, options, next_state) + .. get_infix(child, options, next_state) + .. get_suffix(child, options, next_state) end + return infix end - if comp.is_term(component) or comp.is_pattern(component) then - if comp.is_only_chars(component) then - infix = '`' .. component.text .. '`' + if P.is_capture_group(component) then + local indent = get_indent_string(component, options) + local sublines = M.recurse(component.children, options, extend(state, { + parent = component + })) + local contents = table.concat(sublines, '\n' .. indent) + local name = component.group_name and ('`' .. component.group_name .. '`') or '' + local header = '' + if component.type == 'named_capturing_group' then + header = 'named capture group ' .. component.capture_group .. ' ' .. name + elseif component.type == 'non_capturing_group' then + header = 'non-capturing group ' else - for i, child in ipairs(component.children) do - - local child_clause = get_narrative_clause(child, options, state) - if comp.is_capture_group(child) and comp.is_simple_pattern_character(component.children[i - 1]) then - infix = infix .. '\n' .. child_clause - else - infix = infix .. child_clause - end - end + header = 'capture group ' .. component.capture_group end + header = header:gsub(' $', '') + return '' + .. header + .. get_quantifier(component) + .. ':\n' + .. indent + .. contents end - if comp.is_pattern_character(component) then - infix = '`' .. utils.escape_markdown(component.text) .. '`' + if P.is_character_class(component) then + return '\n' .. D.describe_character_class(component) end - if comp.is_identity_escape(component) - or comp.is_decimal_escape(component) then - local escaped = component.text:gsub([[^\+]], '') - infix = '`' .. escaped .. '`' - - elseif comp.is_special_character(component) then - infix = '**' .. utils.escape_markdown(descriptions.describe_character(component)) .. '**' - - elseif comp.is_escape(component) then - local desc = descriptions.describe_escape(component.text) - infix = '**' .. desc .. '**' - end - - if comp.is_boundary_assertion(component) then - infix = '**WB**' + if P.is_escape(component) then + if P.is_identity_escape(component) then + local text = component.text:sub(2) + if text == '' then text = component.text end + local escaped_text = U.escape_markdown(text) + if escaped_text == ' ' then escaped_text = '(space)' end + return '`' .. escaped_text .. '`' + elseif P.is_decimal_escape(component) then + return '`' .. D.describe_escape(component.text) .. '`' + else + return '**' .. D.describe_escape(component.text) .. '**' + end end - if comp.is_character_class(component) then - infix = descriptions.describe_character_class(component) + if P.is_character_class_escape(component) then + return '**' .. D.describe_escape(component.text) .. '**' end - if comp.is_capture_group(component) then - local sublines, sep = get_sublines(component, options, state) - local contents = table.concat(sublines, sep):gsub(sep .. '$', '') - - infix = get_group_heading(component) - .. get_suffix(component) - .. ':' - .. sep - .. contents - .. '\n' - + if P.is_pattern_character(component) then + return '`' .. U.escape_markdown(component.text) .. '`' end - if comp.is_lookaround_assertion(component) then - if comp.direction == 'behind' then - state.lookbehind_found = true - end + if P.is_lookaround_assertion(component) then + local indent = get_indent_string(component, options) + local sublines = M.recurse(component.children, options, extend(state, { + parent = component + })) + local contents = table.concat(sublines, '\n'..indent) - local negation = component.negative and 'NOT ' or '' + local negation = (component.negative and 'NOT ' or '') local direction = 'followed by' if component.direction == 'behind' then direction = 'preceeding' end - prefix = '**' .. negation .. direction .. ' ' .. '**' - local sublines, sep = get_sublines(component, options, state) - local contents = table.concat(sublines, sep):gsub(sep .. '$', '') + return '' + .. '**' + .. negation + .. direction + .. '**' + .. get_quantifier(component) + .. ':\n' + .. string.rep(indent, component.capture_depth) + .. contents + .. '\n' + end - infix = get_suffix(component) - .. ':' - .. sep - .. contents - .. '\n' + if P.is_special_character(component) then + local infix = '' + infix = infix .. '**' .. D.describe_character(component) .. '**' + if P.is_start_assertion(component) then + infix = infix .. '\n' + end + return infix end - if not comp.is_capture_group(component) - and not comp.is_lookaround_assertion(component) then - suffix = get_suffix(component) + if P.is_boundary_assertion(component) then + return '**WB**' end +end - local clause = prefix .. infix .. suffix +---@param component RegexplainerComponent +---@return nil|RegexplainerComponent error +local function find_error(component) + local error + if component.type == 'ERROR' then + error = component + elseif component.children then + for _, child in ipairs(component.children) do + error = find_error(child) + if error then return error end + end + end + return error +end - return clause +local function get_error_message(error, state) + local lines = {} + lines[1] = '🚨 **Regexp contains an ERROR** at' + lines[2] = '`' .. state.full_regexp_text .. '`' + lines[3] = ' ' + local error_start_col = error.error.position[2][1] + local from_re_start_to_err_start = error_start_col - error.error.start_offset + 1 + for _ = 1, from_re_start_to_err_start do + lines[3] = lines[3] .. ' ' + end + lines[3] = lines[3] .. '^' + return lines end ----@param components RegexplainerComponent[] +local function is_non_empty(line) + return line ~= '' +end + +local function split_lines(clause) + return vim.split(clause, '\n') +end + +local function trim_end(str) + local s = str:gsub(' +$', '') + return s +end + +---@param components (RegexplainerComponent)[] ---@param options RegexplainerOptions ---@param state RegexplainerNarrativeRendererState +---@return string[] lines, RegexplainerNarrativeRendererState state function M.recurse(components, options, state) - state = state or {} local clauses = {} - local lines = {} - for i, component in ipairs(components) do local first = i == 1 local last = i == #components - if component.type == 'ERROR' then - lines[1] = '🚨 **Regexp contains an ERROR** at' - lines[2] = '`' .. state.full_regexp_text .. '`' - lines[3] = ' ' - local error_start_col = component.error.position[2][1] - local from_re_start_to_err_start = error_start_col - component.error.start_offset + 1 - for _ = 1, from_re_start_to_err_start do - lines[3] = lines[3] .. ' ' - end - lines[3] = lines[3] .. '^' - return lines, state - end + local error = find_error(component) - local next_clause = get_narrative_clause(component, - options, - vim.tbl_extend('force', state, { - first = first, - last = last, - })) - - if comp.is_lookaround_assertion(component) then - if not clauses[#clauses] then - table.insert(clauses, next_clause) - else - clauses[#clauses] = clauses[#clauses] .. ' ' .. next_clause - end - else - table.insert(clauses, next_clause) + if error then + return get_error_message(error, state), state end - end - local separator = options.narrative.separator - if type(separator) == "function" then - separator = separator({ type = 'root', depth = 0 }) - end + local next_state = extend(state, { + first = first, + last = last, + parent = state.parent or { type = 'root' } + }) - local narrative = table.concat(clauses, separator) + local clause = '' + .. get_prefix(component, options, next_state) + .. get_infix(component, options, next_state) + .. get_suffix(component, options, next_state) - for line in narrative:gmatch("([^\n]*)\n?") do - if #line > 0 then - table.insert(lines, line) - end + table.insert(clauses, clause) end + local lines = vim.iter(clauses) + :map(split_lines) + :flatten() + :filter(is_non_empty) + :map(trim_end) + :totable() + return lines, state end diff --git a/lua/regexplainer/utils/defer.lua b/lua/regexplainer/utils/defer.lua deleted file mode 100644 index d940c86..0000000 --- a/lua/regexplainer/utils/defer.lua +++ /dev/null @@ -1,182 +0,0 @@ ---- @license MIT hey@runiq.de ---- @see https://github.com/runiq/neovim-throttle-debounce/ --- - -local M = {} - ----Validates args for `throttle()` and `debounce()`. -local function td_validate(fn, ms) - vim.validate { - fn = { fn, 'f' }, - ms = { - ms, - function(ms) - return type(ms) == 'number' and ms > 0 - end, - "number > 0", - }, - } -end - ---- Throttles a function on the leading edge. Automatically `schedule_wrap()`s. ---- ---@param fn function Function to throttle ---@param timeout number Timeout in ms ---@return (function,timer) throttled function and timer. Remember to call ----`timer:close()` at the end or you will leak memory! -function M.throttle_leading(fn, ms) - td_validate(fn, ms) - local timer = vim.loop.new_timer() - local running = false - - local function wrapped_fn(...) - if not running then - timer:start(ms, 0, function() - running = false - end) - running = true - pcall(vim.schedule_wrap(fn), select(1, ...)) - end - end - - return wrapped_fn, timer -end - ---- Throttles a function on the trailing edge. Automatically ---- `schedule_wrap()`s. ---- ---@param fn (function) Function to throttle ---@param timeout (number) Timeout in ms ---@param last (boolean, optional) Whether to use the arguments of the last ----call to `fn` within the timeframe. Default: Use arguments of the first call. ---@returns (function, timer) Throttled function and timer. Remember to call ----`timer:close()` at the end or you will leak memory! -function M.throttle_trailing(fn, ms, last) - td_validate(fn, ms) - local timer = vim.loop.new_timer() - local running = false - - local wrapped_fn - if not last then - function wrapped_fn(...) - if not running then - local argv = { ... } - local argc = select('#', ...) - - timer:start(ms, 0, function() - running = false - pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc)) - end) - running = true - end - end - else - local argv, argc - function wrapped_fn(...) - argv = { ... } - argc = select('#', ...) - - if not running then - timer:start(ms, 0, function() - running = false - pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc)) - end) - running = true - end - end - end - return wrapped_fn, timer -end - ---- Debounces a function on the leading edge. Automatically `schedule_wrap()`s. ---- ---@param fn (function) Function to debounce ---@param timeout (number) Timeout in ms ---@returns (function, timer) Debounced function and timer. Remember to call ----`timer:close()` at the end or you will leak memory! -function M.debounce_leading(fn, ms) - td_validate(fn, ms) - local timer = vim.loop.new_timer() - local running = false - - local function wrapped_fn(...) - timer:start(ms, 0, function() - running = false - end) - - if not running then - running = true - pcall(vim.schedule_wrap(fn), select(1, ...)) - end - end - - return wrapped_fn, timer -end - ---- Debounces a function on the trailing edge. Automatically ---- `schedule_wrap()`s. ---- ---@param fn (function) Function to debounce ---@param timeout (number) Timeout in ms ---@param first (boolean, optional) Whether to use the arguments of the first ----call to `fn` within the timeframe. Default: Use arguments of the last call. ---@returns (function, timer) Debounced function and timer. Remember to call ----`timer:close()` at the end or you will leak memory! -function M.debounce_trailing(fn, ms, first) - td_validate(fn, ms) - local timer = vim.loop.new_timer() - local wrapped_fn - - if not first then - function wrapped_fn(...) - local argv = { ... } - local argc = select('#', ...) - - timer:start(ms, 0, function() - pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc)) - end) - end - else - local argv, argc - function wrapped_fn(...) - argv = argv or { ... } - argc = argc or select('#', ...) - - timer:start(ms, 0, function() - pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc)) - end) - end - end - return wrapped_fn, timer -end - ---- Test deferment methods (`{throttle,debounce}_{leading,trailing}()`). ---- ---@param bouncer (string) Bouncer function to test ---@param ms (number, optional) Timeout in ms, default 2000. ---@param firstlast (bool, optional) Whether to use the 'other' fn call ----strategy. -function M.test_defer(bouncer, ms, firstlast) - local bouncers = { - tl = M.throttle_leading, - tt = M.throttle_trailing, - dl = M.debounce_leading, - dt = M.debounce_trailing, - } - - local timeout = ms or 2000 - - local bounced = bouncers[bouncer]( - function(i) vim.cmd('echom "' .. bouncer .. ': ' .. i .. '"') end, - timeout, - firstlast - ) - - for i, _ in ipairs { 1, 2, 3, 4, 5 } do - bounced(i) - vim.schedule(function() vim.cmd('echom ' .. i) end) - vim.fn.call("wait", { 1000, "v:false" }) - end -end - -return M diff --git a/lua/regexplainer/utils/init.lua b/lua/regexplainer/utils/init.lua index 713b6bb..44bbc09 100644 --- a/lua/regexplainer/utils/init.lua +++ b/lua/regexplainer/utils/init.lua @@ -38,4 +38,36 @@ function M.escape_markdown(str) return string.gsub(str, [==[([\_*`><])]==], [[\%1]]) end +local lookuptables = {} + +setmetatable(lookuptables, { __mode = "v" }) -- make values weak + +local function get_lookup(xs) + local key = type(xs) == 'string' and xs or table.concat(xs, '-') + if lookuptables[key] then return lookuptables[key] + else + local lookup = {} + for _, v in ipairs(xs) do lookup[v] = true end + lookuptables[key] = lookup + return lookup + end +end + +--- Memoized `elem` predicate +---@generic T +---@param x T needle +---@param xs T[] haystack +-- +function M.elem(x, xs) + return get_lookup(xs)[x] or false +end + +function M.get_cached(key) + return lookuptables[key] +end + +function M.set_cached(key, table) + lookuptables[key] = table +end + return M diff --git a/lua/regexplainer/utils/treesitter.lua b/lua/regexplainer/utils/treesitter.lua index b59fbb4..307c104 100644 --- a/lua/regexplainer/utils/treesitter.lua +++ b/lua/regexplainer/utils/treesitter.lua @@ -1,8 +1,11 @@ local M = {} -local GUARD_MAX = 1000 - -local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_node_text +local get_query = vim.treesitter.query.get +local get_parser = vim.treesitter.get_parser +local get_node_text = vim.treesitter.get_node_text +local get_node = vim.treesitter.get_node +local get_captures_at_cursor = vim.treesitter.get_captures_at_cursor +local is_in_node_range = vim.treesitter.is_in_node_range local node_types = { 'alternation', @@ -38,107 +41,31 @@ for _, type in ipairs(node_types) do end end --- Get previous node with same parent ----@param node? TSNode ----@param allow_switch_parents? boolean allow switching parents if first node ----@param allow_previous_parent? boolean allow previous parent if first node and previous parent without children ----@return TSNode? -local function get_previous_node(node, allow_switch_parents, allow_previous_parent) - local destination_node ---@type TSNode? - local parent = node and node:parent() - if not parent then - return - end - - local found_pos = 0 - for i = 0, parent:named_child_count() - 1, 1 do - if parent:named_child(i) == node then - found_pos = i - break +---@param original_node TSNode regex_pattern node +---@return TSNode|nil, integer|nil, string|nil +local function get_pattern(original_node) + local buf = vim.api.nvim_create_buf(false, false) + vim.api.nvim_buf_set_lines(buf, 0, 1,true, { get_node_text(original_node, 0) }) + local node + for _, tree in ipairs(get_parser(buf, 'regex'):parse()) do + node = tree:root() + while node and node:type() ~= 'pattern' do + node = node:child(0) end end - if 0 < found_pos then - destination_node = parent:named_child(found_pos - 1) - elseif allow_switch_parents then - local previous_node = get_previous_node(parent) - if previous_node and previous_node:named_child_count() > 0 then - destination_node = previous_node:named_child(previous_node:named_child_count() - 1) - elseif previous_node and allow_previous_parent then - destination_node = previous_node - end + if node and node:type() == 'pattern' then + return node, buf, nil end - return destination_node + return nil, buf, 'could not find pattern node' end ----@param node? TSNode ----@return TSNode? -local function get_root_for_node(node) - ---@type TSNode? - local parent = node - local result = node - - while parent ~= nil do - result = parent - parent = result:parent() - end - - return result -end - ----@param row number ----@param col number ----@param root_lang_tree vim.treesitter.LanguageTree ----@return TSNode? -local function get_root_for_position(row, col, root_lang_tree) - local lang_tree = root_lang_tree:language_for_range { row, col, row, col } - - for _, tree in pairs(lang_tree:trees()) do - local root = tree:root() - - if root and vim.treesitter.is_in_node_range(root, row, col) then - return root - end - end - - return nil -end - ----@param root_lang_tree vim.treesitter.LanguageTree ----@return TSNode? -local function get_node_at_cursor(root_lang_tree) - local cursor = vim.api.nvim_win_get_cursor(0) - local cursor_range = { cursor[1] - 1, cursor[2] } - - ---@type TSNode? - local root = get_root_for_position(cursor_range[1], cursor_range[2], root_lang_tree) - - if not root then - return - end - - return root:named_descendant_for_range(cursor_range[1], cursor_range[2], cursor_range[1], cursor_range[2]) -end - ----Enter a parent-language's regexp node which contains the embedded ----regexp grammar ----@param root_lang_tree vim.treesitter.LanguageTree ----@param node TSNode ----@return TSNode? -local function enter_js_re_node(root_lang_tree, node) - -- cribbed from get_node_at_cursor impl - local row, col = vim.treesitter.get_node_range(node) - - local root = get_root_for_position(row, col + 1--[[hack that works for js]], root_lang_tree) - - if not root then - root = get_root_for_node(node) - - if not root then - return nil +function M.has_regexp_at_cursor() + for _, cap in ipairs(get_captures_at_cursor(0)) do + if cap == 'string.regexp' then + return true end end - - return root:named_descendant_for_range(row, col + 1, row, col + 1) + return false end ---Containers are regexp treesitter nodes which may contain leaf nodes like pattern_character. @@ -182,26 +109,6 @@ function M.is_punctuation(type) or false end --- Is this the document root (or close enough for our purposes)? ----@param node? TSNode ----@return boolean -function M.is_document(node) - if node == nil then return true else - local type = node:type() - return type == 'program' - or type == 'document' - or type == 'source' - or type == 'source_file' - or type == 'fragment' - or type == 'chunk' - -- if we're in an embedded language - or type == 'stylesheet' - or type == 'haskell' - -- Wha happun? - or type == 'ERROR' and not (M.is_pattern(node:parent()) or M.is_term(node:parent())) - end -end - ---@param node TSNode ---@return unknown function M.is_control_escape(node) @@ -211,15 +118,9 @@ function M.is_control_escape(node) } end --- Should we stop here when traversing upwards through the tree from the cursor node? --- -function M.is_upwards_stop(node) - return node and node:type() == 'pattern' or M.is_document(node) -end - -- Is it a lookaround assertion? function M.is_lookaround_assertion(node) - return require 'regexplainer.component'.is_lookaround_assertion { type = node:type() } + return require 'regexplainer.component.predicates'.is_lookaround_assertion { type = node:type() } end function M.is_modifier(node) @@ -231,83 +132,27 @@ end --- Using treesitter, find the current node at cursor, and traverse up to the --- document root to determine if we're on a regexp ----@param options RegexplainerOptions ----@return any, string|nil +---@return TSNode|nil, integer|nil, string|nil -- -function M.get_regexp_pattern_at_cursor(options) - local filetype = vim.bo[0].ft - if not vim.tbl_contains(options.filetypes, filetype) then - return nil, 'unrecognized filetype' +function M.get_regexp_pattern_at_cursor() + local parser = get_parser(0) + parser:parse() + local query = get_query(parser:lang(), 'regexplainer') + if not query then + return nil, nil, 'could not load regexplainer query for ' .. parser:lang() end - local root_lang_tree = vim.treesitter.get_parser(0, vim.treesitter.language.get_lang(filetype)) - local cursor_node = get_node_at_cursor(root_lang_tree) - local cursor_node_type = cursor_node and cursor_node:type() - if not cursor_node or cursor_node_type == 'program' then - return + local cursor_node = get_node() + if not cursor_node then + return nil, nil, 'could not get node at cursor' end - - ---@type TSNode? - local node = cursor_node - - if node and node:type() == 'regex' then - local iterator = node:iter_children() - - -- break if we enter an infinite loop (probably) - -- - local guard = 0 - while node == cursor_node do - guard = guard + 1 - if guard >= GUARD_MAX then - return nil, 'loop exceeded ' .. GUARD_MAX .. ' at node ' .. (node and node:type() or 'nil') - end - - local next = iterator() - - if not next then - return nil, 'no downwards node' - else - local type = next:type() - - if type == 'pattern' then - node = next - elseif type == 'regex_pattern' or type == 'regex' then - node = enter_js_re_node(root_lang_tree, next) - if not node then - return nil, 'no node immediately to the right of the regexp node' - end - end - end - end - end - - -- break if we enter an infinite loop (probably) - -- - local guard = 0 - while not M.is_upwards_stop(node) do - guard = guard + 1 - if guard >= GUARD_MAX then - return nil, 'loop exceeded ' .. GUARD_MAX .. ' at node ' .. (node and node:type() or 'nil') - end - - local _node = node - node = get_previous_node(node, true, true) - if not node then - node = get_root_for_node(_node) - if not node then - return nil, 'no upwards node' - end + local row, col = cursor_node:range() + for id, node in query:iter_captures(cursor_node:tree():root(), 0, row, row + 1) do + local name = query.captures[id] -- name of the capture in the query + if name == 'regexplainer.pattern' and is_in_node_range(node, row, col) then + return get_pattern(node) end end - - if M.is_document(node) then - return nil, nil - elseif node == cursor_node then - return nil, 'stuck on cursor_node' - elseif not node then - return nil, 'unexpected no downwards node' - end - - return node, nil + return nil, nil, 'no node' end return M diff --git a/queries/javascript/regexplainer.scm b/queries/javascript/regexplainer.scm new file mode 100644 index 0000000..1b23f65 --- /dev/null +++ b/queries/javascript/regexplainer.scm @@ -0,0 +1,2 @@ +(regex + (regex_pattern) @regexplainer.pattern) @regexplainer.regex diff --git a/queries/regex/regexplainer.scm b/queries/regex/regexplainer.scm new file mode 100644 index 0000000..223942e --- /dev/null +++ b/queries/regex/regexplainer.scm @@ -0,0 +1 @@ +(pattern) @regexplainer.pattern diff --git a/tests/fixtures/narrative/01 Simple Patterns.js b/tests/fixtures/narrative/01 Simple Patterns.js index b7534a8..6a83a3b 100644 --- a/tests/fixtures/narrative/01 Simple Patterns.js +++ b/tests/fixtures/narrative/01 Simple Patterns.js @@ -2,7 +2,7 @@ * `a` */ /a/; - + /** * `Hello` */ @@ -27,13 +27,3 @@ * `123` */ /\1\2\3/; - - - - - - - - - - diff --git a/tests/fixtures/narrative/02 Modifiers.js b/tests/fixtures/narrative/02 Modifiers.js index 3254450..a4fd4d7 100644 --- a/tests/fixtures/narrative/02 Modifiers.js +++ b/tests/fixtures/narrative/02 Modifiers.js @@ -1,12 +1,10 @@ /** - * @example EXPECTED: * `hello` * `!` (_optional_) */ -/hello!?/; +/hello!?/; /** - * @example EXPECTED: * `hello` * `.` (_optional_) */ diff --git a/tests/fixtures/narrative/03 Ranges and Quantifiers.js b/tests/fixtures/narrative/03 Ranges and Quantifiers.js index f6dc5e5..b32abcf 100644 --- a/tests/fixtures/narrative/03 Ranges and Quantifiers.js +++ b/tests/fixtures/narrative/03 Ranges and Quantifiers.js @@ -1,48 +1,48 @@ -/** +/** * `@hello` * One of `a-z` */ /@hello[a-z]/; -/** +/** * One of **WB**, **WORD**, **0-9**, **WS**, **TAB**, **LF**, or **CR** */ /[\b\w\d\s\t\n\r]/; -/** +/** * **START** * One of `a-z`, `A-Z`, or `0-9` (_6-12x_) * **END** */ /^[a-zA-Z0-9]{6,12}$/; -/** +/** * One of `-`, **WORD**, or `.` */ /[-\w.]/; -/** +/** * One of **WORD**, `,`, or `.` */ /[\w,.]/; -/** +/** * One of **WORD**, `-`, or `.` */ /[\w\-.]/; -/** +/** * One of `.`, `-`, or **WORD** */ /[.\-\w]/; /** * **0-9** (_>= 0x_) - * ` ` + * `(space)` */ /\d*\ /; -/** +/** * `a` (_1x_) * `b` (_>= 2x_) * `c` (_3-5x_) @@ -51,7 +51,7 @@ */ /a{1}b{2,}c{3,5}d*e+/g; -/** +/** * **WB** * One of `a-z`, `0-9`, `.`, `\_`, `%`, `+`, or `-` (_>= 1x_) * `@hello` diff --git a/tests/fixtures/narrative/04 Negated Ranges.js b/tests/fixtures/narrative/04 Negated Ranges.js index 66fcd1b..8c885ab 100644 --- a/tests/fixtures/narrative/04 Negated Ranges.js +++ b/tests/fixtures/narrative/04 Negated Ranges.js @@ -1,4 +1,4 @@ -/** +/** * **START** * `p` * Any except `p`, `^`, or `a` (_>= 0x_) diff --git a/tests/fixtures/narrative/05 Capture Groups.js b/tests/fixtures/narrative/05 Capture Groups.js index 2a247b9..9e442d4 100644 --- a/tests/fixtures/narrative/05 Capture Groups.js +++ b/tests/fixtures/narrative/05 Capture Groups.js @@ -1,10 +1,23 @@ -/** +/** * `@` * capture group 1: * `hello` */ /@(hello)/; +/** + * capture group 1: + * **0-9** + */ +/(\d)/; + +/** + * **WORD** (_>= 0x_) + * capture group 1: + * **WORD** + */ +/\w*(\w)/; + /** * `@` * capture group 1: diff --git a/tests/fixtures/narrative/07 Non-Capturing Groups.js b/tests/fixtures/narrative/07 Non-Capturing Groups.js index 5432869..f4628ac 100644 --- a/tests/fixtures/narrative/07 Non-Capturing Groups.js +++ b/tests/fixtures/narrative/07 Non-Capturing Groups.js @@ -1,7 +1,16 @@ /** * `hello` * non-capturing group: - * Either `world` or `dolly` + * `world` */ -/hello(?:world|dolly)/; +/hello(?:world)/; + +/** + * `hello` + * non-capturing group: + * `mudda` + * non-capturing group: + * `fadda` + */ +/hello(?:mudda(?:fadda))/; diff --git a/tests/fixtures/narrative/09 Lookaround.js b/tests/fixtures/narrative/09 Lookaround.js index 95b85a5..cf66f18 100644 --- a/tests/fixtures/narrative/09 Lookaround.js +++ b/tests/fixtures/narrative/09 Lookaround.js @@ -1,33 +1,38 @@ /** - * `@` **followed by **: + * `@` + * **followed by**: * `u` * `@` */ /@(?=u)@/; /** - * `@` **NOT followed by **: + * `@` + * **NOT followed by**: * `u` * `@` */ /@(?!u)@/; /** - * `@` **followed by ** (_2-3x_): + * `@` + * **followed by** (_2-3x_): * Either `up` or `down` * `@` */ /@(?=up|down){2,3}@/; /** - * `@` **NOT followed by **: + * `@` + * **NOT followed by**: * One of **WORD**, or **WS** * `@` */ /@(?![\w\s])@/; /** - * `@` **followed by **: + * `@` + * **followed by**: * `g` * non-capturing group (_optional_): * `raph` @@ -37,14 +42,15 @@ /@(?=g(?:raph)?ql)@/; /** - * **preceeding **: + * **preceeding**: * `it's the ` * `attack of the killer tomatos` */ /(?<=it's the )attack of the killer tomatos/; /** - * `x` **NOT preceeding **: + * `x` + * **NOT preceeding**: * `u` * `@` */ @@ -56,7 +62,7 @@ /** - * **preceeding **: + * **preceeding**: * `g` * `\`` * capture group 1: @@ -66,7 +72,7 @@ /(?<=g)`(.*)`/mg; /** - * **preceeding **: + * **preceeding**: * `g` * non-capturing group (_optional_): * `raph` diff --git a/tests/fixtures/narrative/12 Regex Sudoku.js b/tests/fixtures/narrative/12 Regex Sudoku.js index ed69c73..485eb32 100644 --- a/tests/fixtures/narrative/12 Regex Sudoku.js +++ b/tests/fixtures/narrative/12 Regex Sudoku.js @@ -1,16 +1,18 @@ /** * capture group 1: - * **0-9** + * **WORD** */ -/(\d)/; +/(\w)/; + /** * **0-9** (_>= 0x_) * capture group 1: * **0-9** */ /\d*(\d)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_>= 1x_): * **ANY** (_>= 0x_) * **LF** @@ -20,28 +22,31 @@ * **WB** */ /(?!(?:.*\n)+(?:.{10}){0}\1\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * **0-9** (_>= 0x_) - * ` ` + * `(space)` * non-capturing group (_>= 0x_) (_lazy_): * **ANY** (_10x_) * `1` * **WB** */ /(?!\d*\ (?:.{10})*?\1\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * **0-9** (_>= 0x_) - * ` ` + * `(space)` * non-capturing group (_0-1x_): * **ANY** (_10x_) * `1` * **WB** */ /(?!\d*\ (?:.{10}){0,1}\1\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_1-2x_): * **ANY** (_>= 0x_) * **LF** @@ -59,13 +64,16 @@ * **WS** (_>= 1x_) */ /\d*\s+/; + /** - * **0-9** (_>= 0x_) **NOT followed by **: + * **0-9** (_>= 0x_) + * **NOT followed by**: * `1` */ /\d*(?!\1)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_>= 1x_): * **ANY** (_>= 0x_) * **LF** @@ -75,28 +83,31 @@ * **WB** */ /(?!(?:.*\n)+(?:.{10}){1}\2\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * **0-9** (_>= 0x_) - * ` ` + * `(space)` * non-capturing group (_>= 0x_) (_lazy_): * **ANY** (_10x_) * `2` * **WB** */ /(?!\d*\ (?:.{10})*?\2\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * **0-9** (_>= 0x_) - * ` ` + * `(space)` * non-capturing group (_0-0x_): * **ANY** (_10x_) * `2` * **WB** */ /(?!\d*\ (?:.{10}){0,0}\2\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_1-2x_): * **ANY** (_>= 0x_) * **LF** @@ -114,13 +125,16 @@ * **WS** (_>= 1x_) */ /\d*\s+/; + /** - * **0-9** (_>= 0x_) **NOT followed by **: + * **0-9** (_>= 0x_) + * **NOT followed by**: * Either `1` or `2` */ /\d*(?!\1|\2)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_>= 1x_): * **ANY** (_>= 0x_) * **LF** @@ -130,18 +144,20 @@ * **WB** */ /(?!(?:.*\n)+(?:.{10}){2}\3\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * **0-9** (_>= 0x_) - * ` ` + * `(space)` * non-capturing group (_>= 0x_) (_lazy_): * **ANY** (_10x_) * `3` * **WB** */ /(?!\d*\ (?:.{10})*?\3\b)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_1-2x_): * **ANY** (_>= 0x_) * **LF** @@ -160,13 +176,16 @@ * **WS** (_>= 1x_) */ /split\d*\s+/; + /** - * **0-9** (_>= 0x_) **NOT followed by **: + * **0-9** (_>= 0x_) + * **NOT followed by**: * Either `1`, `2`, or `3` */ /\d*(?!\1|\2|\3)/; + /** - * **NOT followed by **: + * **NOT followed by**: * non-capturing group (_>= 1x_): * **ANY** (_>= 0x_) * **LF** @@ -176,8 +195,41 @@ * **WB** */ /(?!(?:.*\n)+(?:.{10}){3}\4\b)/; + +/** + * **NOT followed by**: + * **0-9** (_>= 0x_) + * `(space)` + * non-capturing group (_>= 0x_) (_lazy_): + * **ANY** (_10x_) + * `4` + * **WB** + */ /(?!\d*\ (?:.{10})*?\4\b)/; + +/** + * **NOT followed by**: + * **0-9** (_>= 0x_) + * `(space)` + * non-capturing group (_0-1x_): + * **ANY** (_10x_) + * `4` + * **WB** + */ /(?!\d*\ (?:.{10}){0,1}\4\b)/; + +/** + * **NOT followed by**: + * non-capturing group (_1-2x_): + * **ANY** (_>= 0x_) + * **LF** + * non-capturing group (_1x_): + * **ANY** (_30x_) + * non-capturing group (_0-2x_): + * **ANY** (_10x_) + * `4` + * **WB** + */ /(?!(?:.*\n){1,2}(?:.{30}){1}(?:.{10}){0,2}\4\b)/; /\d*(?!\1|\2|\3|\4)/; @@ -587,8 +639,3 @@ /\d*(?!\9|\18|\27|\36|\45|\54|\61|\62|\63|\70|\71|\72|\73|\74|\75|\76|\77|\78|\79|\80)/; /(?!(?:.*\n)+(?:.{10}){8}\81\b)/; /(?!\d*\ (?:.{10})*?\81\b)/; - -/** - * `Z` - */ -/\Z/; diff --git a/tests/helpers/util.lua b/tests/helpers/util.lua index bd5c4d0..d2279aa 100644 --- a/tests/helpers/util.lua +++ b/tests/helpers/util.lua @@ -1,27 +1,29 @@ local regexplainer = require 'regexplainer' +local buffers = require 'regexplainer.buffers' local parsers = require "nvim-treesitter.parsers" -local get_node_text = vim.treesitter.get_node_text or vim.treesitter.query.get_node_text +local get_parser = vim.treesitter.get_parser +local get_node_text = vim.treesitter.get_node_text +local bd = vim.api.nvim_buf_delete ----@diagnostic disable-next-line: unused-local -local log = require 'regexplainer.utils'.debug +local query = vim.treesitter.query.get('javascript', 'regexplainer_test') +if not query then error('could not get query') end -local M = {} - -M.register_name = 'test' - --- NOTE: ideally, we'd query for the jsdoc description as an injected language, but --- so far I've been unable to make that happen, after that, I tried querying the JSDoc tree --- from the line above the regexp, but that also proved difficult --- so, at long last, we do some sring manipulation +local function trim(s) + return (string.gsub(s, "^%s*(.-)%s*$", "%1")) +end -local parse_query = vim.treesitter.query.parse or vim.treesitter.query.parse_query -local query_js = parse_query('javascript', [[ - (comment) @comment - (expression_statement - (regex)) @expr - ]]) +local function editfile(testfile) + vim.cmd("e! " .. testfile) + assert.are.same( + vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ":p"), + vim.fn.fnamemodify(testfile, ":p") + ) +end +---Parse a JSDoc comment, returning the markdown description +---@param comment string JSDoc comment, including /* */ +---@return string description JSDoc description, without /* * */ local function get_expected_from_jsdoc(comment) local lines = {} for line in comment:gmatch("([^\n]*)\n?") do @@ -33,51 +35,88 @@ local function get_expected_from_jsdoc(comment) table.insert(lines, clean) end - return M.trim(table.concat(lines, '\n')) + return trim(table.concat(lines, '\n')) end +---Retrieve all the cases in a fixture file. +---a case is a regexp expression with a JSDoc comment +---containing the expected regexplainer narrative result local function get_cases() local results = {} local parser = parsers.get_parser(0) local tree = parser:parse()[1] - - for id, node in query_js:iter_captures(tree:root(), 0) do - local name = query_js.captures[id] -- name of the capture in the query - local prev = node:prev_sibling() - if name == 'expr' and prev and prev:type() == 'comment' then - local text = get_node_text(node:named_child('pattern'), 0) - local expected = get_expected_from_jsdoc(get_node_text(prev, 0)) - table.insert(results, { - text = text, - example = expected, - row = node:start(), - }) + local next = {} + for id, node in query:iter_captures(tree:root(), 0) do + local name = query.captures[id] -- name of the capture in the query + if name == 'test.comment' then + local jsdoc_text = get_node_text(node, 0); + next.expected = get_expected_from_jsdoc(jsdoc_text) + elseif name == 'test.pattern' then + next.pattern = get_node_text(node, 0) + next.row = node:start() + 1 + end + if next.row and next.expected and next.pattern then + table.insert(results, next) + next = {} end end - return results end -function M.trim(s) - return (string.gsub(s, "^%s*(.-)%s*$", "%1")) +---Cleanup any remaining buffers +local function clear_buffers() + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + vim.api.nvim_buf_delete(bufnr, { force = true }) + end end -local test_buffers = {} +---@param bufnr number +---@return string text buffer text +local function get_buffer_text(bufnr) + return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') +end -function M.editfile(testfile) - vim.cmd("e! " .. testfile) - assert.are.same( - vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ":p"), - vim.fn.fnamemodify(testfile, ":p") - ) +---@param pattern string regexp pattern to test +---@return number bufnr bufnr of test fixture buffer +local function setup_test_buffer(pattern) + local newbuf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_win_set_buf(0, newbuf) + vim.opt_local.filetype = 'javascript' + vim.api.nvim_set_current_line('/'..pattern..'/;') + vim.treesitter.start(newbuf, 'javascript') + return newbuf end +local function show_and_get_regexplainer_buffer(bufnr) + local buffer + repeat + get_parser(0):parse() + vim.uv.sleep(1) + local row, col = unpack(vim.api.nvim_win_get_cursor(0)); + vim.api.nvim_win_set_cursor(0, {row, col + 1}) + local cursor_node = vim.treesitter.get_node() + if (cursor_node) then + for id, node in query:iter_captures(cursor_node, bufnr) do + if query[id] == 'test.pattern' and node then + local range = node:range() + vim.api.nvim_win_set_cursor(0, { range[0], range[1] }) + end + end + end + regexplainer.show({debug = true}) + buffer = buffers.get_last_buffer() + until buffer + return buffer.bufnr +end + +local M = {} + +M.register_name = 'test' + function M.iter_regexes_with_descriptions(filename) - M.editfile(filename) + editfile(filename) local cases = get_cases() - local index = 0 - return function() index = index + 1 if index <= #cases then @@ -88,83 +127,24 @@ end function M.clear_test_state() vim.fn.setreg(M.register_name, '') - - -- Clear regexplainer state - regexplainer.teardown() - - -- Cleanup any remaining buffers - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - vim.api.nvim_buf_delete(bufnr, { force = true }) - end + regexplainer.teardown() -- Clear regexplainer state + clear_buffers() assert(#vim.api.nvim_list_bufs() == 1, "Failed to properly clear buffers") - assert(#vim.api.nvim_tabpage_list_wins(0) == 1, "Failed to properly clear tab") assert(vim.fn.getreg(M.register_name) == '', "Failed to properly clear register") end -function M.assert_popup_text_at_row(row, expected) - M.editfile(assert:get_parameter('fixture_filename')) - local moved = pcall(vim.api.nvim_win_set_cursor, 0, { row, 1 }) - while moved == false do - M.editfile(assert:get_parameter('fixture_filename')) - end - regexplainer.show() - M.wait_for_regexplainer_buffer() - local bufnr = require 'regexplainer.buffers'.get_buffers()[1].bufnr - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false); - local text = table.concat(lines, '\n') - local regex = vim.api.nvim_buf_get_lines(0, 0, -1, false)[row] - return assert.are.same(expected, text, row .. ': ' .. regex) -end - -function M.assert_string(regexp, expected, message) - local newbuf = vim.api.nvim_create_buf(true, true) - - vim.opt_local.filetype = 'javascript' - vim.api.nvim_set_current_line(regexp) - - local text = table.concat(vim.api.nvim_buf_get_lines( - M.wait_for_regexplainer_buffer(), - 0, - -1, - false - ), '\n') - - regexplainer.hide() - +---@param pattern string regexp pattern to test +---@param expected string expected markdown output +---@param message string test description +function M.assert_string(pattern, expected, message) + local newbufnr = setup_test_buffer(pattern) + local rebufnr = show_and_get_regexplainer_buffer(newbufnr) + local text = get_buffer_text(rebufnr) -- Cleanup any remaining buffers - vim.api.nvim_buf_delete(newbuf, { force = true }) - + bd(newbufnr, { force = true }) + regexplainer.hide() return assert.are.same(expected, text, message) end -function M.sleep(n) - os.execute("sleep " .. tonumber(n)) -end - -function M.wait_for_regexplainer_buffer() - local buffers = {} - local count = 0 - repeat - vim.cmd.norm'l' - regexplainer.show() - count = count + 1 - buffers = require 'regexplainer.buffers'.get_buffers() - until #buffers > 0 or count >= 20 - return buffers[1].bufnr -end - -function M.get_info_on_capture(id, name, node, metadata) - local yes, text = pcall(get_node_text, node, 0) - return { - id, name, - text = yes and text or nil, - metadata = metadata, - type = node:type(), - pos = { node:range() } - } -end - -M.dedent = require 'plenary.strings'.dedent - return M diff --git a/tests/lua/ansicolors.lua b/tests/lua/ansicolors.lua new file mode 100644 index 0000000..f474952 --- /dev/null +++ b/tests/lua/ansicolors.lua @@ -0,0 +1,100 @@ +-- ansicolors.lua v1.0.2 (2012-08) + +-- Copyright (c) 2009 Rob Hoelz +-- Copyright (c) 2011 Enrique García Cota +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +-- THE SOFTWARE. + + +-- support detection +local function isWindows() + return type(package) == 'table' and type(package.config) == 'string' and package.config:sub(1,1) == '\\' +end + +local supported = not isWindows() +if isWindows() then supported = os.getenv("ANSICON") end + +local keys = { + -- reset + reset = 0, + + -- misc + bright = 1, + dim = 2, + underline = 4, + blink = 5, + reverse = 7, + hidden = 8, + + -- foreground colors + black = 30, + red = 31, + green = 32, + yellow = 33, + blue = 34, + magenta = 35, + cyan = 36, + white = 37, + + -- background colors + blackbg = 40, + redbg = 41, + greenbg = 42, + yellowbg = 43, + bluebg = 44, + magentabg = 45, + cyanbg = 46, + whitebg = 47 +} + +local escapeString = string.char(27) .. '[%dm' +local function escapeNumber(number) + return escapeString:format(number) +end + +local function escapeKeys(str) + + if not supported then return "" end + + local buffer = {} + local number + for word in str:gmatch("%w+") do + number = keys[word] + assert(number, "Unknown key: " .. word) + table.insert(buffer, escapeNumber(number) ) + end + + return table.concat(buffer) +end + +local function replaceCodes(str) + str = string.gsub(str,"(%%{(.-)})", function(_, str) return escapeKeys(str) end ) + return str +end + +-- public + +local function ansicolors( str ) + str = tostring(str or '') + + return replaceCodes('%{reset}' .. str .. '%{reset}') +end + + +return setmetatable({noReset = replaceCodes}, {__call = function (_, str) return ansicolors (str) end}) diff --git a/tests/mininit.lua b/tests/mininit.lua index c393f4c..0aff9db 100644 --- a/tests/mininit.lua +++ b/tests/mininit.lua @@ -34,18 +34,19 @@ function M.setup() set noswapfile filetype on set runtimepath=$VIMRUNTIME - runtime plugin/regexplainer.vim + runtime plugin/regexplainer.lua ]]) + local parser_install_dir = M.root'.tests/share/treesitter'; + vim.opt.runtimepath:append(parser_install_dir) vim.opt.runtimepath:append(M.root()) - vim.opt.packpath = { M.root(".tests/site") } + vim.opt.runtimepath:append(M.root'tests') + vim.opt.packpath = { M.root'.tests/site' } - M.load("MunifTanjim/nui.nvim") - M.load("nvim-lua/plenary.nvim") - M.load("nvim-treesitter/nvim-treesitter") + M.load'MunifTanjim/nui.nvim' + M.load'nvim-lua/plenary.nvim' + M.load'nvim-treesitter/nvim-treesitter' - local parser_install_dir = M.root(".tests/share/treesitter"); - vim.opt.runtimepath:append(parser_install_dir) vim.cmd[[packloadall]] diff --git a/tests/queries/javascript/regexplainer_test.scm b/tests/queries/javascript/regexplainer_test.scm new file mode 100644 index 0000000..f009765 --- /dev/null +++ b/tests/queries/javascript/regexplainer_test.scm @@ -0,0 +1,4 @@ +(comment) @test.comment +(expression_statement + (regex + pattern: (regex_pattern) @test.pattern)) @test.expr diff --git a/tests/regexplainer/nvim-regexplainer_spec.lua b/tests/regexplainer/nvim-regexplainer_spec.lua index ffefe33..eb9d0b7 100644 --- a/tests/regexplainer/nvim-regexplainer_spec.lua +++ b/tests/regexplainer/nvim-regexplainer_spec.lua @@ -1,13 +1,8 @@ local Utils = require 'tests.helpers.util' -local log = require 'regexplainer.utils'.debug local regexplainer = require 'regexplainer' local scan = require 'plenary.scandir' -local function setup_narrative() - regexplainer.setup() -end - local function file_filter(filename) local filter = vim.env.REGEXPLAINER_TEST_FILTER or nil if filter then @@ -18,33 +13,32 @@ local function file_filter(filename) end local function row_filter(row) - -- return row == 71 + -- return row <= 12 return true end -describe("Regexplainer", function() - before_each(Utils.clear_test_state) - describe('Narratives', function() - local all_files = scan.scan_dir('tests/fixtures/narrative', { depth = 1 }) - local files = vim.tbl_filter(file_filter, all_files) - for _, file in ipairs(files) do - local category = file:gsub('tests/fixtures/narrative/%d+ (.*)%.js', '%1') - describe(category, function() - before_each(setup_narrative) - for result in Utils.iter_regexes_with_descriptions(file) do - if (row_filter(result.row)) then - it(result.text, function() - Utils.assert_string(result.text, result.example, file .. ':' .. result.row) - end) - end - end - end) - end - end) +local function category_filter(category) + return (false + or category == 'Simple Patterns' + or category == 'Modifiers' + or category == 'Ranges and Quantifiers' + or category == 'Negated Ranges' + or category == 'Capture Groups' + or category == 'Named Capture Groups' + or category == 'Non-Capturing Groups' + or category == 'Alternations' + or category == 'Lookaround' + or category == 'Special Characters' + or category == 'Practical Examples' + or category == 'Regex Sudoku' + or category == 'Errors' + ) +end +describe("Regexplainer", function() describe('Yank', function() it('yanks into a given register', function() - setup_narrative() + regexplainer.setup() local bufnr = vim.api.nvim_create_buf(true, true) local expected = "Either `hello` or `world`\n" @@ -61,4 +55,26 @@ describe("Regexplainer", function() return assert.are.same(expected, actual, 'contents of a') end) end) + before_each(Utils.clear_test_state) + describe('Narratives', function() + local all_files = scan.scan_dir('tests/fixtures/narrative', { depth = 1 }) + local files = vim.tbl_filter(file_filter, all_files) + for _, file in ipairs(files) do + local category = file:gsub('tests/fixtures/narrative/%d+ (.*)%.js', '%1') + if not category_filter(category) then + print(require'ansicolors'('%{yellow}Skipping %{reset}') .. category) + else + describe(category, function() + before_each(regexplainer.setup) + for result in Utils.iter_regexes_with_descriptions(file) do + if (row_filter(result.row)) then + it('/'..result.pattern..'/', function() + Utils.assert_string(result.pattern, result.expected, file .. ':' .. result.row) + end) + end + end + end) + end + end + end) end)