From 41c1b90a54e1d6c8f5b0d79ff05104c3877b5025 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Sun, 26 May 2024 15:28:09 +0300 Subject: [PATCH] fix!: query down, not up fix: lookaround narratives fix: don't debounce refactor: remove last global state refactor: destructure apis BREAKING CHANGES: - require tree-sitter-regexp 0.21 - require nvim 0.10 - replace `narrative.separator` with `narrative.indentation_string` - change `component.depth` to `component.capture_depth` in `narrative.indentation_string` function --- README.md | 36 +- lua/regexplainer.lua | 114 +++--- lua/regexplainer/buffers/init.lua | 64 ++- lua/regexplainer/buffers/popup.lua | 30 +- lua/regexplainer/buffers/split.lua | 28 +- lua/regexplainer/component/descriptions.lua | 22 +- lua/regexplainer/component/init.lua | 278 ++++--------- lua/regexplainer/component/predicates.lua | 116 ++++++ lua/regexplainer/renderers/init.lua | 1 + lua/regexplainer/renderers/narrative/init.lua | 18 +- .../renderers/narrative/narrative.lua | 386 ++++++++++-------- lua/regexplainer/utils/defer.lua | 182 --------- lua/regexplainer/utils/init.lua | 32 ++ lua/regexplainer/utils/treesitter.lua | 237 ++--------- queries/javascript/regexplainer.scm | 2 + queries/regex/regexplainer.scm | 1 + .../narrative/07 Non-Capturing Groups.js | 9 + 17 files changed, 614 insertions(+), 942 deletions(-) create mode 100644 lua/regexplainer/component/predicates.lua delete mode 100644 lua/regexplainer/utils/defer.lua create mode 100644 queries/javascript/regexplainer.scm create mode 100644 queries/regex/regexplainer.scm 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/07 Non-Capturing Groups.js b/tests/fixtures/narrative/07 Non-Capturing Groups.js index ff7326f..f4628ac 100644 --- a/tests/fixtures/narrative/07 Non-Capturing Groups.js +++ b/tests/fixtures/narrative/07 Non-Capturing Groups.js @@ -5,3 +5,12 @@ */ /hello(?:world)/; +/** + * `hello` + * non-capturing group: + * `mudda` + * non-capturing group: + * `fadda` + */ +/hello(?:mudda(?:fadda))/; +