diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c3c51c3f..7c93ce9d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} version: v0.19.0 # CLI arguments - args: --color always --check . + args: --color always --respect-ignores --check . gendoc: name: Document generation diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b87c073a..22163464 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,7 @@ repos: name: StyLua language: system entry: stylua + args: [--respect-ignores] types: [lua] - id: gendocs name: Gendocs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d72393e..2dffd8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - FEATURE: update `grep` and `grep_live` pickers to allow `globs` local option which restricts search to files that match any of its glob patterns (for example, `{ '*.lua', 'lua/**' }` will only search in Lua files and files in 'lua' directory). The `grep_live` picker also has custom `` mapping to add globs interactively after picker is opened. +## mini.snippets + +- Introduction of a new module. + ## mini.surround - BREAKING: created mappings for `find`, `find_left`, and `highlight` are now *not* dot-repeatable. Dot-repeat should repeat last text change but neither of those actions change text. Having them dot-repeatable breaks the common "move cursor -> press dot" workflow. Initially making them dot-repeatable was a "you can but you should not" type of mistake. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15bc28c9..13ab8fa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -244,6 +244,14 @@ Here is a list of all highlight groups defined inside 'mini.nvim' modules. See d - `MiniPickPreviewRegion` - `MiniPickPrompt` +- 'mini.snippets': + + - `MiniSnippetsCurrent` + - `MiniSnippetsCurrentReplace` + - `MiniSnippetsFinal` + - `MiniSnippetsUnvisited` + - `MiniSnippetsVisited` + - 'mini.starter': - `MiniStarterCurrent` - `MiniStarterFooter` diff --git a/README.md b/README.md index 012a1752..5965c090 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ If you are browsing without particular objective and don't know which module to | mini.pairs | Autopairs | [README](readmes/mini-pairs.md) | [Help file](doc/mini-pairs.txt) | | mini.pick | Pick anything | [README](readmes/mini-pick.md) | [Help file](doc/mini-pick.txt) | | mini.sessions | Session management | [README](readmes/mini-sessions.md) | [Help file](doc/mini-sessions.txt) | +| mini.snippets | Manage and expand snippets | [README](readmes/mini-snippets.md) | [Help file](doc/mini-snippets.txt) | | mini.splitjoin | Split and join arguments | [README](readmes/mini-splitjoin.md) | [Help file](doc/mini-splitjoin.txt) | | mini.starter | Start screen | [README](readmes/mini-starter.md) | [Help file](doc/mini-starter.txt) | | mini.statusline | Statusline | [README](readmes/mini-statusline.md) | [Help file](doc/mini-statusline.txt) | @@ -167,7 +168,6 @@ This is the list of modules I currently intend to implement eventually (as my fr - 'mini.cycle' - cycle through alternatives with pre-defined rules. Something like [monaqa/dial.nvim](https://github.com/monaqa/dial.nvim) and [AndrewRadev/switch.vim](https://github.com/AndrewRadev/switch.vim) - 'mini.keymap' - utilities to make non-trivial mappings (like [max397574/better-escape.nvim](https://github.com/max397574/better-escape.nvim) and dot-repeatable mappings). -- 'mini.snippets' - work with snippets. Something like [L3MON4D3/LuaSnip](https://github.com/L3MON4D3/LuaSnip) but only with more straightforward functionality. - 'mini.statuscolumn' - customizable 'statuscolumn'. - 'mini.terminals' - coherently manage terminal windows and send text from buffers to terminal windows. Something like [kassio/neoterm](https://github.com/kassio/neoterm). - 'mini.quickfix' - fuzzy search and preview of quickfix entries. Possibly with some presets for populating quickfix list (like files, help tags, etc.). Similar to [kevinhwang91/nvim-bqf](https://github.com/kevinhwang91/nvim-bqf). diff --git a/doc/mini-snippets.txt b/doc/mini-snippets.txt new file mode 100644 index 00000000..b5ddb21b --- /dev/null +++ b/doc/mini-snippets.txt @@ -0,0 +1,1147 @@ +*mini.snippets* Manage and expand snippets +*MiniSnippets* + +MIT License Copyright (c) 2024 Evgeni Chasnovski + +============================================================================== + +Snippet is a template for a frequently used text. Typical workflow is to type +snippet's (configurable) prefix and expand it into a snippet session. + +The template usually contains both pre-defined text and places (called +"tabstops") for user to interactively change/add text during snippet session. + +This module supports (only) snippet syntax defined in LSP specification (with +small deviations). See |MiniSnippets-syntax-specification|. + +Features: +- Manage snippet collection by adding it explicitly or with a flexible set of + performant built-in loaders. See |MiniSnippets.gen_loader|. + +- Configured snippets are efficiently resolved before every expand based on + current local context. This, for example, allows using different snippets + in different local tree-sitter languages (like in markdown code blocks). + See |MiniSnippets.default_prepare()|. + +- Match which snippet to insert based on the currently typed text. + Supports both exact and fuzzy matching. See |MiniSnippets.default_match()|. + +- Select from several matched snippets via `vim.ui.select()`. + See |MiniSnippets.default_select()|. + +- Insert, jump, and edit during snippet session in a configurable manner: + - Configurable mappings for jumping and stopping. + - Jumping wraps around the tabstops for easier navigation. + - Easy to reason rules for when session automatically stops. + - Text synchronization of linked tabstops. + - Dynamic tabstop state visualization (current/visited/unvisited, etc.) + - Inline visualization of empty tabstops (requires Neovim>=0.10). + - Works inside comments by preserving comment leader on new lines. + - Supports nested sessions (expand snippet while there is an one active). + See |MiniSnippets.default_insert()|. + +- Exported function to parse snippet body into easy-to-reason data structure. + See |MiniSnippets.parse()|. + +Notes: +- It does not set up any snippet collection by default. Explicitly populate + `config.snippets` to have snippets to match from. +- It does not come with a built-in snippet collection. It is expected from + users to add their own snippets, manually or with a dedicated plugin(s). +- It does not support variable/tabstop transformations in default snippet + session. This requires ECMAScript Regular Expression parser which can not + be implemented concisely. + +Sources with more details: +- |MiniSnippets-glossary| +- |MiniSnippets-overview| +- |MiniSnippets-examples| + +# Setup ~ + +This module needs a setup with `require('mini.snippets').setup({})` (replace `{}` +with your `config` table). It will create global Lua table `MiniSnippets` which +you can use for scripting or manually (with `:lua MiniSnippets.*`). + +See |MiniSnippets.config| for `config` structure and default values. + +You can override runtime config settings locally to buffer inside +`vim.b.minisnippets_config` which should have same structure as +`Minisnippets.config`. See |mini.nvim-buffer-local-config| for more details. + +# Comparisons ~ + +- 'L3MON4D3/LuaSnip': + - Both contain functionality to load snippets from file system. + This module provides several common loader generators while 'LuaSnip' + contains a more elaborate loading setup. + Also both require explicit opt-in for which snippets to load. + - Both support LSP snippet format. 'LuaSnip' also provides own more + elaborate snippet format which is out of scope for this module. + - Both contain snippet expand functionality which differs in some aspects: + - 'LuaSnip' has an elaborate dynamic tabstop visualization config. + This module provides a handful of dedicated highlight groups. + - This module provides configurable visualization of empty tabstops. + - 'LusSnip' implements nested sessions by essentially merging them + into one. This module treats each nested session separately (to not + visually overload) while storing them in stack (first in last out). + - 'LuaSnip' uses |Select-mode| to power replacing current tabstop, + while this module always stays in |Insert-mode|. This enables easier + mapping understanding and more targeted highlighting. + - This module implements jumping which wraps after final tabstop + for more flexible navigation (enhanced with by a more flexible + autostopping rules), while 'LuaSnip' autostops session once + jumping reached the final tabstop. + +- Built-in |vim.snippet| (on Neovim>=0.10): + - Does not contain functionality to load or match snippets (by design), + while this module does. + - Both contain expand functionality based on LSP snippet format. + Differences in how snippet sessions are handled are similar to + comparison with 'LuaSnip'. + +- 'rafamadriz/friendly-snippets': + - A snippet collection plugin without features to manage or expand them. + This module is designed with 'friendly-snippets' compatibility in mind. + +# Highlight groups ~ + +* `MiniSnippetsCurrent` - current tabstop. +* `MiniSnippetsCurrentReplace` - current tabstop, placeholder is to be replaced. +* `MiniSnippetsFinal` - special `$0` tabstop. +* `MiniSnippetsUnvisited` - not yet visited tabstop(s). +* `MiniSnippetsVisited` - visited tabstop(s). + +To change any highlight group, modify it directly with |:highlight|. + +# Disabling ~ + +To disable core functionality, set `vim.g.minisnippets_disable` (globally) or +`vim.b.minisnippets_disable` (for a buffer) to `true`. Considering high number +of different scenarios and customization intentions, writing exact rules +for disabling module's functionality is left to user. See +|mini.nvim-disabling-recipes| for common recipes. + +------------------------------------------------------------------------------ + *MiniSnippets-glossary* +`POSITION` Table representing position in a buffer. Fields: + - `(number)` - line number (starts at 1). + - `(number)` - column number (starts at 1). + +`REGION` Table representing region in a buffer. + Fields: and for inclusive start/end POSITIONs. + +`SNIPPET` Data about template to insert. Should contain fields: + - - string snippet identifier. + - - string snippet content with appropriate syntax. + - - string snippet description in human readable form. + Can also be used to mean snippet body if distinction is clear. + +`SNIPPET SESSION` Interactive state for user to adjust inserted snippet. + +`MATCHED SNIPPET` SNIPPET which contains field with REGION that + matched it. Usually region needs to be removed. + +`SNIPPET NODE` Unit of parsed SNIPPET body. See |MiniSnippets.parse()|. + +`TABSTOP` Dedicated places in SNIPPET body for users to interactively + adjust. Specified in snippet body with `$` followed by digit(s). + +`LINKED TABSTOPS` Different nodes assigned the same tabstop. Updated in sync. + +`REFERENCE NODE` First (from left to right) node of linked tabstops. + Used to determine synced text and cursor placement after jump. + +`EXPAND` Action to start snippet session based on currently typed text. + Always done in current buffer at cursor. Executed steps: + - `PREPARE` - resolve raw config snippets at context. + - `MATCH` - match resolved snippets at cursor position. + - `SELECT` - possibly choose among matched snippets. + - `INSERT` - insert selected snippet and start snippet session. + +------------------------------------------------------------------------------ + *MiniSnippets-overview* +Snippet is a template for a frequently used text. Typical workflow is to type +snippet's (configurable) prefix and expand it into a snippet session: add some +pre-defined text and allow user to interactively change/add at certain places. + +This overview assumes default config for mappings and expand. +See |MiniSnippets.config| and |MiniSnippets-examples| for more details. + +# Snippet structure ~ + +Snippet consists from three parts: +- `Prefix` - identifier used to match against current text. +- `Body` - actually inserted content with appropriate syntax. +- `Desc` - description in human readable form. + +Example: `{ prefix = 'tis', body = 'This is snippet', desc = 'Snip' }` +Typing `tis` and pressing "expand" mapping ( by default) will remove "tis", +add "This is snippet", and place cursor at the end in Insert mode. + + *MiniSnippets-syntax-specification* +# Syntax ~ + +Inserting just text after typing smaller prefix is already powerful enough. +For more flexibility, snippet body can be formatted in a special way to +provide extra features. This module implements support for syntax defined +in LSP specification (with small deviations). See this link for reference: +https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#snippet_syntax + +A quick overview of basic syntax features: + +- Tabstops are snippet parts meant for interactive editing at their location. + They are denoted as `$1`, `$2`, etc. + Navigating between them is called "jumping" and is done in numerical order + of tabstop identifiers by pressing special keys: and to jump + to next and previous tabstop respectively. + Special tabstop `$0` is called "final tabstop": it is used to decide when + snippet session is automatically stopped and is visited last during jumping. + + Example: `T1=$1 T2=$2 T0=$0` is expanded as `T1= T2= T0=` with three tabstops. + +- Tabstop can have placeholder: a text used if tabstop is not yet edited. + Text is preserved if no editing is done. It follows this same syntax, which + means it can itself contain tabstops with placeholders (i.e. be nested). + Tabstop with placeholder is denoted as `${1:placeholder}` (`$1` is `${1:}`). + + Example: `T1=${1:text} T2=${2:<$1>}` is expanded as `T1=text T2=`; + typing `x` at first placeholder results in `T1=x T2=`; + jumping once and typing `y` results in `T1=x T2=y`. + +- There can be several tabstops with same identifier. They are linked and + updated in sync during text editing. Can also have different placeholders; + they are forced to be the same as in the first (from left to right) tabstop. + + Example: `T1=${1:text} T1=$1` is expanded as `T1=text T1=text`; + typing `x` at first placeholder results in `T1=x T1=x`. + +- Tabstop can also have choices: suggestions about tabstop text. It is denoted + as `${1|a,b,c|}`. Choices are shown (with |ins-completion| like interface) + after jumping to tabstop. First choice is used as placeholder. + + Example: `T1=${1|left,right|}` is expanded as `T1=left`. + +- Variables can be used to automatically insert text without user interaction. + As tabstops, each one can have a placeholder which is used if variable is + not defined. There is a special set of variables describing editor state. + + Example: `V1=$TM_FILENAME V2=${NOTDEFINED:placeholder}` is expanded as + `V1=current-file-basename V2=placeholder`. + +What's different from LSP specification: +- Special set of variables is wider and is taken from VSCode specification: + https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables + Exceptions are `BLOCK_COMMENT_START` and `BLOCK_COMMENT_END` as Neovim doesn't + provide this information. +- Variable `TM_SELECTED_TEXT` is resolved as contents of |quote_quote| register. + It assumes that text is put there prior to expanding. For example, visually + select, press |c|, type prefix, and expand. +- Environment variables are recognized and supported: `V1=$VIMRUNTIME` will + use an actual value of |$VIMRUNTIME|. +- Variable transformations are not supported during snippet session. It would + require interacting with ECMAScript-like regular expressions for which there + is no easy way in Neovim. It may change in the future. + Transformations are recognized during parsing, though, with some exceptions: + - The `}` inside `if` of `${1:?if:else}` needs escaping (for technical reasons). + +There is a |MiniSnippets.parse()| function for programmatically parsing +snippet body into a comprehensible data structure. + +# Expand ~ + +Using snippets is done via what is called "expanding". It goes like this: +- Type snippet prefix or its recognizable part. +- Press to expand. It will perform the following steps: + - Prepare available snippets in current context (buffer + local language). + This allows having general function loaders in snippet setup. + - Match text to the left of cursor with available prefixes. It first tries + to do exact match and falls back to fuzzy matching. + - If there are several matches, use `vim.ui.select()` to choose one. + - Insert single matching snippet. If snippet contains tabstops, start + snippet session. + +For more details about each step see: +- |MiniSnippets.default_prepare()| +- |MiniSnippets.default_match()| +- |MiniSnippets.default_select()| +- |MiniSnippets.default_insert()| + +Snippet session allows interactive editing at tabstop locations: + +- All tabstop locations are visualized depending on tabstop "state" (whether + it is current/visited/unvisited/final and whether it was already edited). + Empty tabstops are visualized with inline virtual text ("•"/"∎" for + regular/final tabstops). It is removed after session is stopped. + +- Start session at first tabstop. Type text to replace placeholder. + When finished with current tabstop, jump to next with . Repeat. + If changed mind about some previous tabstop, jump back with . + Jumping also wraps around the edge (first tabstop is next after final). + +- Starting another snippet session while there is one active is allowed. + This creates nested sessions: current is suspended, new one is started. + After newly created is stopped, the suspended one is resumed. + +- Stop session manually by pressing or it will be done automatically: + either by making text edit or exiting in Normal mode when final tabstop is + current. If snippet doesn't explicitly define final tabstop, it is added at + the end of the snippet. + +For more details about snippet session see |MiniSnippets-session|. + +# Management ~ + +Out of the box 'mini.snippets' doesn't load any snippets, it should be done +explicitly inside |MiniSnippets.setup()| following |MiniSnippets.config|. + +The suggested approach to snippet management is to create dedicated files with +snippet data and load them through function loaders in `config.snippets`. +See |MiniSnippets-examples| for basic (yet capable) snippet management config. + + *MiniSnippets-file-specification* +General idea of supported files is to have at least out of the box experience +with common snippet collections. Namely "rafamadriz/friendly-snippets". +The following files are supported: + +- Extensions: + - Read/decoded as JSON object (|vim.json.decode()|): `*.json`, `*.code-snippets` + - Executed as Lua file (|dofile()|) and uses returned value: `*.lua` + +- Content: + - Dict-like: object in JSON; returned table in Lua; no order guarantees. + - Array-like: array in JSON; returned array table in Lua; preserves order. + +Example of file content with a single snippet: +- Lua dict-like: `return { name = { prefix = 'l', body = 'local $1 = $0' } }` +- Lua array-like: `return { { prefix = 'l', body = 'local $1 = $0' } }` +- JSON dict-like: `{ "name": { "prefix": "l", "body": "local $1 = $0" } }` +- JSON array-like: `[ { "prefix": "l", "body": "local $1 = $0" } ]` + +General advice: +- Put files in "snippets" subdirectory of any path in 'runtimepath' (like + "$XDG_CONFIG_HOME/nvim/snippets/global.json"). + This is compatible with |MiniSnippets.gen_loader.from_runtime()|. +- Prefer `*.json` files with dict-like content if you want more cross-platfrom + setup. Otherwise use `*.lua` files with array-like content. + +Notes: +- There is no built-in support for VSCode-like "package.json" files. Define + structure manually in |MiniSnippets.setup()| via built-in or custom loaders. +- There is no built-in support for `scope` field of snippet data. Snippets are + expected to be manually separated into smaller files and loaded on demand. + +For supported snippet syntax see |MiniSnippets-syntax-specification|. + +# Demo ~ + +The best way to grasp the design of snippet management and expansion is to +try them out yourself. Here are steps for a basic demo: +- Create 'snippets/global.json' file in the config directory with the content: > + + { + "Basic": { "prefix": "ba", "body": "T1=$1 T2=$2 T0=$0" }, + "Placeholders": { "prefix": "pl", "body": "T1=${1:aa}\nT2=${2:<$1>}" }, + "Choices": { "prefix": "ch", "body": "T1=${1|a,b|} T2=${2|c,d|}" }, + "Linked": { "prefix": "li", "body": "T1=$1\nT1=$1" }, + "Variables": { "prefix": "va", "body": "Runtime: $VIMRUNTIME\n" }, + "Complex": { + "prefix": "co", + "body": [ "T2=${2:$RANDOM}", "T1=${1:<$2>}", "T2=$2", "T1=$1" ] + } + } +< +- Set up 'mini.snippets' as recommended in |MiniSnippets-examples|. +- Open Neovim. Type each snippet prefix and press (even if there is + still active session). Explore from there. + +------------------------------------------------------------------------------ + *MiniSnippets-examples* +# Basic snippet management config ~ + +Example of snippet management setup that should cover most cases: >lua + + -- Setup + local gen_loader = require('mini.snippets').gen_loader + require('mini.snippets').setup({ + snippets = { + -- Load custom file with global snippets first + gen_loader.from_file('~/.config/nvim/snippets/global.json'), + + -- Load snippets based on current language by reading files from + -- "snippets/" subdirectories from 'runtimepath' directories. + gen_loader.from_lang(), + }, + }) +< +This setup allows having single file with custom "global" snippets (will be +present in every buffer) and snippets which will be loaded based on the local +language (see |MiniSnippets.gen_loader.from_lang()|). + +Create language snippets manually (by creating and populating +'$XDG_CONFIG_HOME/nvim/snippets/lua.json' file) or by installing dedicated +snippet collection plugin (like 'rafamadriz/friendly-snippets'). + +# Select from all available snippets in current context ~ + +With |MiniSnippets.default_match()|, expand snippets ( by default) at line +start or after whitespace. To be able to always select from all current +context snippets, make mapping similar to the following: >lua + + local rhs = function() MiniSnippets.expand({ match = false }) end + vim.keymap.set('i', '', rhs, { desc = 'Expand all' }) +< +# "Supertab"-like / mappings ~ + +This module intentionally by default uses separate keys to expand and jump as +it enables cleaner use of nested sessions. Here is an example of setting up +custom to "expand or jump" and to "jump to previous": >lua + + local snippets = require('mini.snippets') + local match_strict = function(snippets) + -- Do not match with whitespace to cursor's left + return snippets.default_match(snippets, { pattern_fuzzy = '%S+' }) + end + snippets.setup({ + -- ... Set up snippets ... + mappings = { expand = '', jump_next = '', jump_prev = '' }, + expand = { match = match_strict }, + }) + local expand_or_jump = function() + local can_expand = #MiniSnippets.expand({ insert = false }) > 0 + if can_expand then vim.schedule(MiniSnippets.expand); return '' end + local is_active = MiniSnippets.session.get() ~= nil + if is_active then MiniSnippets.session.jump('next'); return '' end + return '\t' + end + local jump_prev = function() MiniSnippets.session.jump('prev') end + vim.keymap.set('i', '', expand_or_jump, { expr = true }) + vim.keymap.set('i', '', jump_prev) +< +# Stop session immediately after jumping to final tabstop ~ + +Utilize a dedicated |MiniSnippets-events|: >lua + + local fin_stop = function(args) + if args.data.tabstop_to == '0' then MiniSnippets.session.stop() end + end + local au_opts = { pattern = 'MiniSnippetsSessionJump', callback = fin_stop } + vim.api.nvim_create_autocmd('User', au_opts) +< +# Using Neovim's built-ins to insert snippet ~ + +Define custom `expand.insert` in |MiniSnippets.config| and mappings: >lua + + require('mini.snippets').setup({ + -- ... Set up snippets ... + expand = { + insert = function(snippet, _) vim.snippet.expand(snippet.body) end + } + }) + -- Make jump mappings or skip to use built-in / in Neovim>=0.11 + local jump_next = function() + if vim.snippet.active({direction = 1}) then return vim.snippet.jump(1) end + end + local jump_prev = function() + if vim.snippet.active({direction = -1}) then vim.snippet.jump(-1) end + end + vim.keymap.set({ 'i', 's' }, '', jump_next) + vim.keymap.set({ 'i', 's' }, '', jump_prev) +< +# Using 'mini.snippets' in other plugins ~ + + Plugins which want to start 'mini.snippets' session given a snippet body + (similar to |vim.snippet.expand()|) are recommended to use the following: >lua + + -- Check that `MiniSnippets` is set up by the user + if MiniSnippets ~= nil then + -- Use configured `insert` method with falling back to default + local insert = MiniSnippets.config.expand.insert + or MiniSnippets.default_insert + -- Insert at cursor + insert({ body = snippet }) + end +< +------------------------------------------------------------------------------ + *MiniSnippets.setup()* + `MiniSnippets.setup`({config}) +Module setup + +Parameters ~ +{config} `(table|nil)` Module config table. See |MiniSnippets.config|. + +Usage ~ +>lua + require('mini.snippets').setup({}) -- replace {} with your config table + -- needs `snippets` field present +< +------------------------------------------------------------------------------ + *MiniSnippets.config* + `MiniSnippets.config` +Module config + +Default values: +>lua + MiniSnippets.config = { + -- Array of snippets and loaders (see |MiniSnippets.config| for details). + -- Nothing is defined by default. Add manually to have snippets to match. + snippets = {}, + + -- Module mappings. Use `''` (empty string) to disable one. + mappings = { + -- Expand snippet at cursor position. Created globally in Insert mode. + expand = '', + + -- Interact with default `expand.insert` session. + -- Created for the duration of active session(s) + jump_next = '', + jump_prev = '', + stop = '', + }, + + -- Functions describing snippet expansion. If `nil`, default values + -- are `MiniSnippets.default_()`. + expand = { + -- Resolve raw config snippets at context + prepare = nil, + -- Match resolved snippets at cursor position + match = nil, + -- Possibly choose among matched snippets + select = nil, + -- Insert selected snippet + insert = nil, + }, + } +< +# Loaded snippets ~ + +`config.snippets` is an array containing snippet data which can be: snippet +table, function loader, or (however deeply nested) array of snippet data. + +Snippet is a table with the following fields: + +- `(string|table|nil)` - string used to match against current text. + If array, all strings should be used as separate prefixes. +- `(string|table|nil)` - content of a snippet which should follow + the |MiniSnippets-syntax-specification|. Array is concatenated with "\n". +- `(string|table|nil)` - description of snippet. Can be used to display + snippets in a more human readable form. Array is concatenated with "\n". + +Function loaders are expected to be called with single `context` table argument +(containing any data about current context) and return same as `config.snippets` +data structure. + +`config.snippets` is resolved with `config.prepare` on every expand. +See |MiniSnippets.default_prepare()| for how it is done by default. + +For a practical example see |MiniSnippets-examples|. +Here is an illustration of `config.snippets` customization capabilities: >lua + + local gen_loader = require('mini.snippets').gen_loader + require('mini.snippets').setup({ + snippets = { + -- Load custom file with global snippets first (order matters) + gen_loader.from_file('~/.config/nvim/snippets/global.json'), + + -- Or add them here explicitly + { prefix='cdate', body='$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE' }, + + -- Load snippets based on current language by reading files from + -- "snippets/" subdirectories from 'runtimepath' directories. + gen_loader.from_lang(), + + -- Load project-local snippets with `gen_loader.from_file()` + -- and relative path (file doesn't have to be present) + gen_loader.from_file('.vscode/project.code-snippets'), + + -- Custom loader for language-specific project-local snippets + function(context) + local rel_path = '.vscode/' .. context.lang .. '.code-snippets' + if vim.fn.filereadable(rel_path) == 0 then return end + return MiniSnippets.read_file(rel_path) + end, + + -- Ensure that some prefixes are not used (as there is no `body`) + { prefix = { 'bad', 'prefix' } }, + } + }) +< +# Mappings ~ + +`config.mappings` describes which mappings are automatically created. + +`mappings.expand` is created globally in Insert mode and is used to expand +snippet at cursor. Use |MiniSnippets.expand()| for custom mappings. + +`mappings.jump_next`, `mappings.jump_prev`, and `mappings.stop` are created for +the duration of active snippet session(s) from |MiniSnippets.default_insert()|. +Used to jump to next/previous tabstop and stop active session respectively. +Use |MiniSnippets.session.jump()| and |MiniSnippets.session.stop()| for custom +Insert mode mappings. + +# Expand ~ + +`config.expand` defines expand steps (see |MiniSnippets-glossary|), either after +pressing `mappings.expand` or starting manually via |MiniSnippets.expand()|. + +`expand.prepare` is a function that takes `raw_snippets` in the form of +`config.snippets` and should return a plain array of snippets (as described +in |MiniSnippets-glossary|). Will be called on every |MiniSnippets.expand()| call. +If returns second value, it will be used as context for warning messages. +Default: |MiniSnippets.default_prepare()|. + +`expand.match` is a function that takes `expand.prepare` output and returns +an array of matched snippets: one or several snippets user might intend to +eventually insert. Should sort matches in output from best to worst. +Entries can contain `region` field with current buffer region used to do +the match; usually it needs to be removed (similar to how |ins-completion| +and |abbreviations| work). +Default: |MiniSnippets.default_match()| + +`expand.select` is a function that takes output of `expand.match` and function +that inserts snippet (and also ensures Insert mode and removes snippet's match +region). Should allow user to perform interactive snippet selection and +insert the chosen one. Designed to be compatible with |vim.ui.select()|. +Called for any non-empty `expand.match` output (even with single entry). +Default: |MiniSnippets.default_select()| + +`expand.insert` is a function that takes single snippet table as input and +inserts snippet at cursor position. This is a main entry point for adding +text template to buffer and starting a snippet session. +If called inside |MiniSnippets.expand()| (which is a usual interactive case), +all it has to do is insert snippet at cursor position. Ensuring Insert mode +and removing matched snippet region is done beforehand. +Default: |MiniSnippets.default_insert()| + +Illustration of `config.expand` customization: >lua + + -- Supply extra data as context + local my_p = function(raw_snippets) + local _, cont = MiniSnippets.default_prepare({}) + cont.cursor = vim.api.nvim_win_get_cursor() + return MiniSnippets.default_prepare(raw_snippets, { context = cont }) + end + -- Perform fuzzy match based only on alphanumeric characters + local my_m = function(snippets, pos) + return MiniSnippets.default_match(snippets, pos, {pattern_fuzzy = '%w*'}) + end + -- Always insert the best matched snippet + local my_s = function(snippets, insert) return insert(snippets[1]) end + -- Use different string to show empty tabstop as inline virtual text + local my_i = function(snippet) + return MiniSnippets.default_insert(snippet, { empty_tabstop = '$' }) + end + + require('mini.snippets').setup({ + -- ... Set up snippets ... + expand = { prepare = my_p, match = my_m, select = my_s, insert = my_i } + }) +< +------------------------------------------------------------------------------ + *MiniSnippets.expand()* + `MiniSnippets.expand`({opts}) +Expand snippet at cursor position + +Perform expand steps (see |MiniSnippets-glossary|). +Initial raw snippets are taken from `config.snippets` in current buffer. +Snippets from `vim.b.minisnippets_config` are appended to global snippet array. + +Parameters ~ +{opts} `(table|nil)` Options. Same structure as `expand` in |MiniSnippets.config| + and uses its values as default. There are differences in allowed values: + - Use `match = false` to have all buffer snippets as matches. + - Use `select = false` to always expand the best match (if any). + - Use `insert = false` to return all matches without inserting. + + Note: `opts.insert` is called after ensuring Insert mode, removing snippet's + match region, and positioning cursor. + +Return ~ +`(table|nil)` If `insert` is `false`, an array of matched snippets (`expand.match` + output). Otherwise `nil`. + +Usage ~ +>lua + -- Match, maybe select, and insert + MiniSnippets.expand() + + -- Match and force expand the best match (if any) + MiniSnippets.expand({ select = false }) + + -- Use all current context snippets as matches + MiniSnippets.expand({ match = false }) + + -- Get all matched snippets + local matches = MiniSnippets.expand({ insert = false }) + + -- Get all current context snippets + local all = MiniSnippets.expand({ match = false, insert = false }) +< +------------------------------------------------------------------------------ + *MiniSnippets.gen_loader* + `MiniSnippets.gen_loader` +Generate snippet loader + +This is a table with function elements. Call to actually get a loader. + +Common features for all produced loaders: +- Designed to work with |MiniSnippets-file-specification|. +- Cache output by default, i.e. second and later calls with same input value + don't read file system. Different loaders from same generator share cache. + Disable by setting `opts.cache` to `false`. + To clear all cache, call |MiniSnippets.setup()|. For example: + `MiniSnippets.setup(MiniSnippets.config)` +- Use |vim.notify()| to show problems during loading while trying to load as + much correctly defined snippet data as possible. + Disable by setting `opts.silent` to `true`. + +------------------------------------------------------------------------------ + *MiniSnippets.gen_loader.from_lang()* + `MiniSnippets.gen_loader.from_lang`({opts}) +Generate language loader + +Output loads files from "snippets/" subdirectories of 'runtimepath' matching +configured language patterns. +See |MiniSnippets.gen_loader.from_runtime()| for runtime loading details. + +Language is taken from field (if present with string value) of `context` +argument used in loader calls during "prepare" stage. +This is compatible with |MiniSnippets.default_prepare()| and most snippet +collection plugins. + +Parameters ~ +{opts} `(table|nil)` Options. Possible values: + - `(table)` - map from language to array of runtime patterns + used to find snippet files, as in |MiniSnippets.gen_loader.from_runtime()|. + Patterns will be processed in order. With |MiniSnippets.default_prepare()| + it means if snippets have same prefix, data from later patterns is used. + + Default pattern array (for non-empty language) is constructed as to read + `*.json` and `*.lua` files that are: + - Inside "snippets/" subdirectory named as language (files can be however + deeply nested). + - Named as language and is in "snippets/" directory (however deep). + Example for "lua" language: >lua + { 'lua/**/*.json', 'lua/**/*.lua', '**/lua.json', '**/lua.lua' } +< + Add entry for `""` (empty string) as language to be sourced when `lang` + context is empty string (which is usually temporary scratch buffers). + + - `(boolean)` - whether to use cached output. Default: `true`. + Note: caching is done per used runtime pattern, not `lang` value to allow + different `from_lang()` loaders to share cache. + - `(boolean)` - whether to hide non-error messages. Default: `false`. + +Return ~ +`(function)` Snippet loader. + +Usage ~ +>lua + -- Adjust language patterns + local latex_patterns = { 'latex/**/*.json', '**/latex.json' } + local lang_patterns = { tex = latex_patterns, plaintex = latex_patterns } + local gen_loader = require('mini.snippets').gen_loader + require('mini.snippets').setup({ + snippets = { + gen_loader.from_lang({ lang_patterns = lang_patterns }), + }, + }) +< +------------------------------------------------------------------------------ + *MiniSnippets.gen_loader.from_runtime()* + `MiniSnippets.gen_loader.from_runtime`({pattern}, {opts}) +Generate runtime loader + +Output loads files which match `pattern` inside "snippets/" directories from +'runtimepath'. This is useful to simultaneously read several similarly +named files from different sources. Order from 'runtimepath' is preserved. + +Typical case is loading snippets for a language from files like `xxx.{json,lua}` +but located in different "snippets/" directories inside 'runtimepath'. +- ``/snippets/lua.json - manually curated snippets in user config. +- ``/snippets/lua.json - from installed plugin. +- ``/after/snippets/lua.json - used to adjust snippets from plugins. + For example, remove some snippets by using prefixes and no body. + +Parameters ~ +{pattern} `(string)` Pattern of files to read. Can have wildcards as described + in |nvim_get_runtime_file()|. Example for "lua" language: `'lua.{json,lua}'`. +{opts} `(table|nil)` Options. Possible fields: + - `(boolean)` - whether to load from all matching runtime files. + Default: `true`. + - `(boolean)` - whether to use cached output. Default: `true`. + Note: caching is done per `pattern` value, which assumes that both + 'runtimepath' value and snippet files do not change during Neovim session. + Caching this way gives significant speed improvement by reducing the need + to traverse file system on every snippet expand. + - `(boolean)` - whether to hide non-error messages. Default: `false`. + +Return ~ +`(function)` Snippet loader. + +------------------------------------------------------------------------------ + *MiniSnippets.gen_loader.from_file()* + `MiniSnippets.gen_loader.from_file`({path}, {opts}) +Generate single file loader + +Output is a thin wrapper around |MiniSnippets.read_file()| which will skip +warning if file is absent (other messages are still shown). Use it to load +file which is not guaranteed to exist (like project-local snippets). + +Parameters ~ +{path} `(string)` Same as in |MiniSnippets.read_file()|. +{opts} `(table|nil)` Same as in |MiniSnippets.read_file()|. + +Return ~ +`(function)` Snippet loader. + +------------------------------------------------------------------------------ + *MiniSnippets.read_file()* + `MiniSnippets.read_file`({path}, {opts}) +Read file with snippet data + +Parameters ~ +{path} `(string)` Path to file with snippets. Can be relative. + See |MiniSnippets-file-specification| for supported file formats. +{opts} `(table|nil)` Options. Possible fields: + - `(boolean)` - whether to use cached output. Default: `true`. + Note: Caching is done per full path only after successful reading. + - `(boolean)` - whether to hide non-error messages. Default: `false`. + +Return ~ +`(table|nil)` Array of snippets or `nil` if failed (also warn with |vim.notify()| + about the reason). + +------------------------------------------------------------------------------ + *MiniSnippets.default_prepare()* + `MiniSnippets.default_prepare`({raw_snippets}, {opts}) +Default prepare + +Normalize raw snippets (as in `snippets` from |MiniSnippets.config|) based on +supplied context: +- Traverse and flatten nested arrays. Function loaders are executed with + `opts.context` as argument and output is processed recursively. +- Ensure unique non-empty prefixes: later ones completely override earlier + ones (similar to how |ftplugin| and similar runtime design behave). + Empty string prefixes are all added (to allow inserting without matching). +- Transform and infer fields: + - Multiply array `prefix` into several snippets with same body/description. + Infer absent `prefix` as empty string. + - Concatenate array `body` with "\n". Do not infer absent `body` to have + it remove previously added snippet with the same prefix. + - Concatenate array `desc` with "\n". Infer `desc` field from `description` + (for compatibility) or `body` fields, in that order. +- Sort output by prefix. + +Unlike |MiniSnippets.gen_loader| entries, there is no output caching. This +avoids duplicating data from `gen_loader` cache and reduces memory usage. +It also means that every |MiniSnippets.expand()| call prepares snippets, which +is usually fast enough. If not, consider manual caching: >lua + + local cache = {} + local prepare_cached = function(raw_snippets) + local _, cont = MiniSnippets.default_prepare({}) + local id = 'buf=' .. cont.buf_id .. ',lang=' .. cont.lang + if cache[id] then return unpack(vim.deepcopy(cache[id])) end + local snippets = MiniSnippets.default_prepare(raw_snippets) + cache[id] = vim.deepcopy({ snippets, cont }) + return snippets, cont + end +< +Parameters ~ +{raw_snippets} `(table)` Array of snippet data as from |MiniSnippets.config|. +{opts} `(table|nil)` Options. Possible fields: + - `(any)` - Context used as an argument for callable snippet data. + Default: table with (current buffer identifier) and (local + language) fields. Language is computed from tree-sitter parser at cursor + (allows different snippets in injected languages), 'filetype' otherwise. + +Return ~ +`(...)` Array of snippets and supplied context (default if none was supplied). + +------------------------------------------------------------------------------ + *MiniSnippets.default_match()* + `MiniSnippets.default_match`({snippets}, {opts}) +Default match + +Match snippets based on the line before cursor. + +Tries two matching approaches consecutively: +- Find exact snippet prefix (if present and non-empty) to the left of cursor. + It should also be preceded with a byte that matches `pattern_exact_boundary`. + In case of any match, return the one with the longest prefix. +- Match fuzzily snippet prefixes against the base (text to the left of cursor + extracted via `opts.pattern_fuzzy`). Matching is done via |matchfuzzy()|. + Empty base results in all snippets being matched. Return all fuzzy matches. + +Parameters ~ +{snippets} `(table)` Array of snippets which can be matched. +{opts} `(table|nil)` Options. Possible fields: + - `(string)` - Lua pattern for the byte to the left + of exact match to accept it. Line start is matched against empty string; + use `?` quantifier to allow it as boundary. + Default: `[%s%p]?` (accept only whitespace and punctuation as boundary, + allow match at line start). + Example: prefix "l" matches in lines `l`, `_l`, `x l`; but not `1l`, `ll`. + - `(string)` - Lua pattern to extract base to the left of + cursor for fuzzy matching. Supply empty string to skip this step. + Default: `'%S*'` (as many as possible non-whitespace; allow empty string). + +Return ~ +`(table)` Array of matched snippets ordered from best to worst match. + +Usage ~ +>lua + -- Accept any exact match + MiniSnippets.default_match(snippets, { pattern_exact_boundary = '.?' }) + + -- Perform fuzzy match based only on alphanumeric characters + MiniSnippets.default_match(snippets, { pattern_fuzzy = '%w*' }) +< +------------------------------------------------------------------------------ + *MiniSnippets.default_select()* + `MiniSnippets.default_select`({snippets}, {insert}, {opts}) +Default select + +Show snippets as |vim.ui.select()| items and insert the chosen one. +For best interactive experience requires `vim.ui.select()` to work from Insert +mode (be properly called and restore Insert mode after choice). +This is the case for at least |MiniPick.ui_select()| and Neovim's default. + +Parameters ~ +{snippets} `(table)` Array of snippets (as an output of `config.expand.match`). +{insert} `(function|nil)` Function to insert chosen snippet (passed as the only + argument). Expected to remove snippet's match region (if present as a field) + and ensure proper cursor position in Insert mode. + Default: |MiniSnippets.default_insert()|. +{opts} `(table|nil)` Options. Possible fields: + - `(boolean)` - whether to skip |vim.ui.select()| for `snippets` + with a single entry and insert it directly. Default: `true`. + +------------------------------------------------------------------------------ + *MiniSnippets.default_insert()* + `MiniSnippets.default_insert`({snippet}, {opts}) +Default insert + +Prepare for snippet insert and do it: +- Ensure Insert mode. +- Delete snippet's match region (if present as field). Ensure cursor. +- Parse snippet body with |MiniSnippets.parse()| and enabled `normalize`. + In particular, evaluate variables, ensure final node presence and same + text for nodes with same tabstops. Stop if not able to. +- Insert snippet at cursor: + - Add snippet's text. Lines are split at "\n". + Indent and left comment leaders (inferred from 'commentstring' and + 'comments') of current line are repeated on the next. + Tabs ("\t") are expanded according to 'expandtab' and 'shiftwidth'. + - If there is an actionable tabstop (not final), start snippet session. + + *MiniSnippets-session* +# Session life cycle ~ + +- Start with cursor at first tabstop. If there are linked tabstops, cursor + is placed at start of reference node (see |MiniSnippets-glossary|). + All tabstops are visualized with dedicated highlight groups (see "Highlight + groups" section in |MiniSnippets|). + Empty tabstops are visualized with inline virtual text ("•"/"∎" for + regular/final tabstops) meaning that it is not an actual text in the + buffer and will be removed after session is stopped. + +- Decide whether you want to replace the placeholder. If not, jump to next or + previous tabstop. If yes, edit it: add new and/or delete already added text. + While doing so, several things happen in all linked tabstops (if any): + + - After first typed character the placeholder is removed and highlighting + changes from `MiniSnippetsCurrentReplace` to `MiniSnippetsCurrent`. + - Text in all tabstop nodes is synchronized with the reference one. + Note: text sync is forced only for current tabstop (for performance). + +- Jump with / to next / previous tabstop. Exact keys can be + adjusted in |MiniSnippets.config| `mappings`. + See |MiniSnippets.session.jump()| for jumping details. + +- Nest another session by expanding snippet in the same way as without + active session (can be even done in another buffer). If snippet has no + actionable tabstop, text is just inserted. Otherwise start nested session: + + - Suspend current session: hide highlights, keep text change tracking. + - Start new session and act as if it is the only one (edit/jump/nest). + - When ready (possibly after even more nested sessions), stop the session. + This will resume previous one: sync text for its current tabstop and + show highlighting. + The experience of text synchronization only after resuming session is + similar to how editing in |visual-block| mode works. + Nothing else (like cursor/mode/buffer) is changed for a smoother + automated session stop. + + Notes about the choice of the "session stack" approach to nesting over more + common "merge into single session" approach: + - Does not overload with highlighting. + - Allows nested sessions in different buffers. + - Doesn't need a complex logic of injecting one session into another. + +- Repeat edit/jump/nest steps any number of times. + +- Stop. It can be done in two ways: + + - Manually by pressing or calling |MiniSnippets.session.stop()|. + Exact key can be adjusted in |MiniSnippets.config| `mappings`. + - Automatically: any text edit or switching to Normal mode stops session + if final tabstop (`$0`) is current. Its presence is ensured after insert. + Not stopping session right away after jumping to final mode (as most + other snippet plugins do) allows going back to other tabstops in case + of a late missed typo. Wrapping around the edge during jumping also + helps with that. + If current tabstop is not final, exiting into Normal mode for quick edit + outside of snippets range (or carefully inside) is fine. Later get back + into Insert mode and jump to next tabstop or manually stop session. + See |MiniSnippets-examples| for how to set up custom stopping rules. + +Use |MiniSnippets.session.get()| to get data about active/nested session(s). +Use |MiniSnippets.session.jump()| / |MiniSnippets.session.stop()| in mappings. + +What is allowed but not officially supported/recommended: + +- Editing text within snippet range but outside of session life cycle. Mostly + behaves as expected, but may harm tracking metadata (|extmarks|). + In general anything but deleting tabstop range should be OK. + Text synchronization of current tabstop would still be active. + + *MiniSnippets-events* +# Events ~ + +General session activity (autocommand data contains field): +- `MiniSnippetsSessionStart` - after a session is started. +- `MiniSnippetsSessionStop` - before a session is stopped. + +Nesting session activity (autocommand data contains field): +- `MiniSnippetsSessionSuspend` - before a session is suspended. +- `MiniSnippetsSessionResume` - after a session is resumed. + +Jumping between tabstops (autocommand data contains and + fields): +- `MiniSnippetsSessionJumpPre` - before jumping to a new tabstop. +- `MiniSnippetsSessionJump` - after jumping to a new tabstop. + +Parameters ~ +{snippet} `(table)` Snippet table. Field is mandatory. +{opts} `(table|nil)` Options. Possible fields: + - `(string)` - used to visualize empty regular tabstops. + Default: "•". + - `(string)` - used to visualize empty final tabstop(s). + Default: "∎". + - `(table)` - passed to |MiniSnippets.parse()|. + Default: `{}`. + +------------------------------------------------------------------------------ + *MiniSnippets.session* + `MiniSnippets.session` +Work with snippet session from |MiniSnippets.default_insert()| + +------------------------------------------------------------------------------ + *MiniSnippets.session.get()* + `MiniSnippets.session.get`({all}) +Get data about active session + +Parameters ~ +{all} `(boolean)` Whether to return array with the whole session stack. + Default: `false`. + +Return ~ +`(table)` Single table with session data (if `all` is `false`) or array of them. + Session data contains the following fields: + - `(number)` - identifier of session's buffer. + - `(string)` - identifier of session's current tabstop. + - `(number)` - |extmark| identifier which track session range. + - `(table)` - |MiniSnippets.default_insert()| arguments used to + create the session. A table with and fields. + - `(table)` - parsed array of snippet nodes which is kept up to date + during session. Has the structure of a normalized |MiniSnippets.parse()| + output, plus every node contains `extmark_id` field with |extmark| identifier + which can be used to get data about the current node state. + - `(number)` - |namespace| identifier for all session's extmarks. + - `(table)` - data about session's tabstops. Fields are string + tabstop identifiers and values are tables with the following fields: + - `(boolean)` - whether tabstop was visited. + - `(string)` - identifier of the next tabstop. + - `(string)` - identifier of the previous tabstop. + +------------------------------------------------------------------------------ + *MiniSnippets.session.jump()* + `MiniSnippets.session.jump`({direction}) +Jump to next/previous tabstop + +Make next/previous tabstop be current. Executes the following steps: +- Mark current tabstop as visited. +- Find the next/previous tabstop id assuming they are sorted as numbers. + Tabstop "0" is always last. Search is wrapped around the edges: first and + final tabstops are next/previous for one another. +- Focus on target tabstop: + - Ensure session's buffer is current. + - Adjust highlighting of affected nodes. + - Set cursor at tabstop's reference node (first node among linked). + Cursor is placed on left edge if tabstop has not been edited yet (so + typing text replaces placeholder), on right edge otherwise (to update + already edited text). + - Show relevant choices for tabstop with choices. They are computed by + matching to tabstop text: choice is matched if starts with it. + Note: if 'completeopt' contains "fuzzy" flag, perform fuzzy matching + with |matchfuzzy()|. + +Parameters ~ +{direction} `(string)` One of "next" or "prev". + +------------------------------------------------------------------------------ + *MiniSnippets.session.stop()* + `MiniSnippets.session.stop`() +Stop (only) active session + +To stop all nested sessions use the following code: >lua + + while MiniSnippets.session.get() do + MiniSnippets.session.stop() + end +< +------------------------------------------------------------------------------ + *MiniSnippets.parse()* + `MiniSnippets.parse`({snippet_body}, {opts}) +Parse snippet + +Parameters ~ +{snippet_body} `(string|table)` Snippet body as string or array of strings. + Should follow |MiniSnippets-syntax-specification|. +{opts} `(table|nil)` Options. Possible fields: + - `(boolean)` - whether to normalize nodes: + - Evaluate variable nodes and add output as a `text` field. + If variable is not set, `text` field is `nil`. + Values from `opts.lookup` are preferred over evaluation output. + See |MiniSnippets-syntax-specification| for more info about variables. + - Add `text` field for tabstops present in `opts.lookup`. + - Ensure every node contains exactly one of `text` or `placeholder` fields. + If there are none, add default `placeholder` (one text node with first + choice or empty string). If there are both, remove `placeholder` field. + - Ensure present final tabstop: append to end if absent. + - Ensure that nodes for same tabstop have same placeholder. Use the one + from the first node. + Default: `false`. + - `(table)` - map from variable/tabstop (string) name to its value. + Default: `{}`. + +Return ~ +`(table)` Array of nodes. Node is a table with fields depending on node type: + - Text node: + - `(string)` - node's text. + - Tabstop node: + - `(string)` - tabstop identifier. + - `(string|nil)` - tabstop value (if present in ). + - `(table|nil)` - array of nodes to be used as placeholder. + - `(table|nil)` - array of string choices. + - `(table|nil)` - array of transformation string parts. + - Variable node: + - `(string)` - variable name. + - `(string|nil)` - variable value. + - `(table|nil)` - array of nodes to be used as placeholder. + - `(table|nil)` - array of transformation string parts. + + + vim:tw=78:ts=8:noet:ft=help:norl: \ No newline at end of file diff --git a/doc/mini.txt b/doc/mini.txt index 1779f2e4..af62fe82 100644 --- a/doc/mini.txt +++ b/doc/mini.txt @@ -46,6 +46,7 @@ Table of contents: Autopairs ..................................................... |mini.pairs| Pick anything .................................................. |mini.pick| Session management ......................................... |mini.sessions| + Manage and expand snippets ................................. |mini.snippets| Split and join arguments .................................. |mini.splitjoin| Start screen ................................................ |mini.starter| Statusline ............................................... |mini.statusline| @@ -262,6 +263,11 @@ Table of contents: using |mksession|. Implements both global (from configured directory) and local (from current directory) sessions. +- |MiniSnippets| - manage and expand snippets. Supports only syntax from LSP + specification. Provides flexible loaders to manage snippet files, exact and + fuzzy prefix matching, interactive selection, and rich interactive snippet + session experience with dynamic tabstop visualization. + - |MiniSplitjoin| - split and join arguments (regions inside brackets between allowed separators). Has customizable pre and post hooks. Works inside comments. diff --git a/lua/mini/init.lua b/lua/mini/init.lua index 432ddee9..91ccdac3 100644 --- a/lua/mini/init.lua +++ b/lua/mini/init.lua @@ -46,6 +46,7 @@ --- Autopairs ..................................................... |mini.pairs| --- Pick anything .................................................. |mini.pick| --- Session management ......................................... |mini.sessions| +--- Manage and expand snippets ................................. |mini.snippets| --- Split and join arguments .................................. |mini.splitjoin| --- Start screen ................................................ |mini.starter| --- Statusline ............................................... |mini.statusline| @@ -262,6 +263,11 @@ --- using |mksession|. Implements both global (from configured directory) and --- local (from current directory) sessions. --- +--- - |MiniSnippets| - manage and expand snippets. Supports only syntax from LSP +--- specification. Provides flexible loaders to manage snippet files, exact and +--- fuzzy prefix matching, interactive selection, and rich interactive snippet +--- session experience with dynamic tabstop visualization. +--- --- - |MiniSplitjoin| - split and join arguments (regions inside brackets --- between allowed separators). Has customizable pre and post hooks. --- Works inside comments. diff --git a/lua/mini/snippets.lua b/lua/mini/snippets.lua new file mode 100644 index 00000000..99cfcbdf --- /dev/null +++ b/lua/mini/snippets.lua @@ -0,0 +1,2534 @@ +--- *mini.snippets* Manage and expand snippets +--- *MiniSnippets* +--- +--- MIT License Copyright (c) 2024 Evgeni Chasnovski +--- +--- ============================================================================== +--- +--- Snippet is a template for a frequently used text. Typical workflow is to type +--- snippet's (configurable) prefix and expand it into a snippet session. +--- +--- The template usually contains both pre-defined text and places (called +--- "tabstops") for user to interactively change/add text during snippet session. +--- +--- This module supports (only) snippet syntax defined in LSP specification (with +--- small deviations). See |MiniSnippets-syntax-specification|. +--- +--- Features: +--- - Manage snippet collection by adding it explicitly or with a flexible set of +--- performant built-in loaders. See |MiniSnippets.gen_loader|. +--- +--- - Configured snippets are efficiently resolved before every expand based on +--- current local context. This, for example, allows using different snippets +--- in different local tree-sitter languages (like in markdown code blocks). +--- See |MiniSnippets.default_prepare()|. +--- +--- - Match which snippet to insert based on the currently typed text. +--- Supports both exact and fuzzy matching. See |MiniSnippets.default_match()|. +--- +--- - Select from several matched snippets via `vim.ui.select()`. +--- See |MiniSnippets.default_select()|. +--- +--- - Insert, jump, and edit during snippet session in a configurable manner: +--- - Configurable mappings for jumping and stopping. +--- - Jumping wraps around the tabstops for easier navigation. +--- - Easy to reason rules for when session automatically stops. +--- - Text synchronization of linked tabstops. +--- - Dynamic tabstop state visualization (current/visited/unvisited, etc.) +--- - Inline visualization of empty tabstops (requires Neovim>=0.10). +--- - Works inside comments by preserving comment leader on new lines. +--- - Supports nested sessions (expand snippet while there is an one active). +--- See |MiniSnippets.default_insert()|. +--- +--- - Exported function to parse snippet body into easy-to-reason data structure. +--- See |MiniSnippets.parse()|. +--- +--- Notes: +--- - It does not set up any snippet collection by default. Explicitly populate +--- `config.snippets` to have snippets to match from. +--- - It does not come with a built-in snippet collection. It is expected from +--- users to add their own snippets, manually or with a dedicated plugin(s). +--- - It does not support variable/tabstop transformations in default snippet +--- session. This requires ECMAScript Regular Expression parser which can not +--- be implemented concisely. +--- +--- Sources with more details: +--- - |MiniSnippets-glossary| +--- - |MiniSnippets-overview| +--- - |MiniSnippets-examples| +--- +--- # Setup ~ +--- +--- This module needs a setup with `require('mini.snippets').setup({})` (replace `{}` +--- with your `config` table). It will create global Lua table `MiniSnippets` which +--- you can use for scripting or manually (with `:lua MiniSnippets.*`). +--- +--- See |MiniSnippets.config| for `config` structure and default values. +--- +--- You can override runtime config settings locally to buffer inside +--- `vim.b.minisnippets_config` which should have same structure as +--- `Minisnippets.config`. See |mini.nvim-buffer-local-config| for more details. +--- +--- # Comparisons ~ +--- +--- - 'L3MON4D3/LuaSnip': +--- - Both contain functionality to load snippets from file system. +--- This module provides several common loader generators while 'LuaSnip' +--- contains a more elaborate loading setup. +--- Also both require explicit opt-in for which snippets to load. +--- - Both support LSP snippet format. 'LuaSnip' also provides own more +--- elaborate snippet format which is out of scope for this module. +--- - Both contain snippet expand functionality which differs in some aspects: +--- - 'LuaSnip' has an elaborate dynamic tabstop visualization config. +--- This module provides a handful of dedicated highlight groups. +--- - This module provides configurable visualization of empty tabstops. +--- - 'LusSnip' implements nested sessions by essentially merging them +--- into one. This module treats each nested session separately (to not +--- visually overload) while storing them in stack (first in last out). +--- - 'LuaSnip' uses |Select-mode| to power replacing current tabstop, +--- while this module always stays in |Insert-mode|. This enables easier +--- mapping understanding and more targeted highlighting. +--- - This module implements jumping which wraps after final tabstop +--- for more flexible navigation (enhanced with by a more flexible +--- autostopping rules), while 'LuaSnip' autostops session once +--- jumping reached the final tabstop. +--- +--- - Built-in |vim.snippet| (on Neovim>=0.10): +--- - Does not contain functionality to load or match snippets (by design), +--- while this module does. +--- - Both contain expand functionality based on LSP snippet format. +--- Differences in how snippet sessions are handled are similar to +--- comparison with 'LuaSnip'. +--- +--- - 'rafamadriz/friendly-snippets': +--- - A snippet collection plugin without features to manage or expand them. +--- This module is designed with 'friendly-snippets' compatibility in mind. +--- +--- # Highlight groups ~ +--- +--- * `MiniSnippetsCurrent` - current tabstop. +--- * `MiniSnippetsCurrentReplace` - current tabstop, placeholder is to be replaced. +--- * `MiniSnippetsFinal` - special `$0` tabstop. +--- * `MiniSnippetsUnvisited` - not yet visited tabstop(s). +--- * `MiniSnippetsVisited` - visited tabstop(s). +--- +--- To change any highlight group, modify it directly with |:highlight|. +--- +--- # Disabling ~ +--- +--- To disable core functionality, set `vim.g.minisnippets_disable` (globally) or +--- `vim.b.minisnippets_disable` (for a buffer) to `true`. Considering high number +--- of different scenarios and customization intentions, writing exact rules +--- for disabling module's functionality is left to user. See +--- |mini.nvim-disabling-recipes| for common recipes. + +--- `POSITION` Table representing position in a buffer. Fields: +--- - `(number)` - line number (starts at 1). +--- - `(number)` - column number (starts at 1). +--- +--- `REGION` Table representing region in a buffer. +--- Fields: and for inclusive start/end POSITIONs. +--- +--- `SNIPPET` Data about template to insert. Should contain fields: +--- - - string snippet identifier. +--- - - string snippet content with appropriate syntax. +--- - - string snippet description in human readable form. +--- Can also be used to mean snippet body if distinction is clear. +--- +--- `SNIPPET SESSION` Interactive state for user to adjust inserted snippet. +--- +--- `MATCHED SNIPPET` SNIPPET which contains field with REGION that +--- matched it. Usually region needs to be removed. +--- +--- `SNIPPET NODE` Unit of parsed SNIPPET body. See |MiniSnippets.parse()|. +--- +--- `TABSTOP` Dedicated places in SNIPPET body for users to interactively +--- adjust. Specified in snippet body with `$` followed by digit(s). +--- +--- `LINKED TABSTOPS` Different nodes assigned the same tabstop. Updated in sync. +--- +--- `REFERENCE NODE` First (from left to right) node of linked tabstops. +--- Used to determine synced text and cursor placement after jump. +--- +--- `EXPAND` Action to start snippet session based on currently typed text. +--- Always done in current buffer at cursor. Executed steps: +--- - `PREPARE` - resolve raw config snippets at context. +--- - `MATCH` - match resolved snippets at cursor position. +--- - `SELECT` - possibly choose among matched snippets. +--- - `INSERT` - insert selected snippet and start snippet session. +---@tag MiniSnippets-glossary + +--- Snippet is a template for a frequently used text. Typical workflow is to type +--- snippet's (configurable) prefix and expand it into a snippet session: add some +--- pre-defined text and allow user to interactively change/add at certain places. +--- +--- This overview assumes default config for mappings and expand. +--- See |MiniSnippets.config| and |MiniSnippets-examples| for more details. +--- +--- # Snippet structure ~ +--- +--- Snippet consists from three parts: +--- - `Prefix` - identifier used to match against current text. +--- - `Body` - actually inserted content with appropriate syntax. +--- - `Desc` - description in human readable form. +--- +--- Example: `{ prefix = 'tis', body = 'This is snippet', desc = 'Snip' }` +--- Typing `tis` and pressing "expand" mapping ( by default) will remove "tis", +--- add "This is snippet", and place cursor at the end in Insert mode. +--- +--- *MiniSnippets-syntax-specification* +--- # Syntax ~ +--- +--- Inserting just text after typing smaller prefix is already powerful enough. +--- For more flexibility, snippet body can be formatted in a special way to +--- provide extra features. This module implements support for syntax defined +--- in LSP specification (with small deviations). See this link for reference: +--- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#snippet_syntax +--- +--- A quick overview of basic syntax features: +--- +--- - Tabstops are snippet parts meant for interactive editing at their location. +--- They are denoted as `$1`, `$2`, etc. +--- Navigating between them is called "jumping" and is done in numerical order +--- of tabstop identifiers by pressing special keys: and to jump +--- to next and previous tabstop respectively. +--- Special tabstop `$0` is called "final tabstop": it is used to decide when +--- snippet session is automatically stopped and is visited last during jumping. +--- +--- Example: `T1=$1 T2=$2 T0=$0` is expanded as `T1= T2= T0=` with three tabstops. +--- +--- - Tabstop can have placeholder: a text used if tabstop is not yet edited. +--- Text is preserved if no editing is done. It follows this same syntax, which +--- means it can itself contain tabstops with placeholders (i.e. be nested). +--- Tabstop with placeholder is denoted as `${1:placeholder}` (`$1` is `${1:}`). +--- +--- Example: `T1=${1:text} T2=${2:<$1>}` is expanded as `T1=text T2=`; +--- typing `x` at first placeholder results in `T1=x T2=`; +--- jumping once and typing `y` results in `T1=x T2=y`. +--- +--- - There can be several tabstops with same identifier. They are linked and +--- updated in sync during text editing. Can also have different placeholders; +--- they are forced to be the same as in the first (from left to right) tabstop. +--- +--- Example: `T1=${1:text} T1=$1` is expanded as `T1=text T1=text`; +--- typing `x` at first placeholder results in `T1=x T1=x`. +--- +--- - Tabstop can also have choices: suggestions about tabstop text. It is denoted +--- as `${1|a,b,c|}`. Choices are shown (with |ins-completion| like interface) +--- after jumping to tabstop. First choice is used as placeholder. +--- +--- Example: `T1=${1|left,right|}` is expanded as `T1=left`. +--- +--- - Variables can be used to automatically insert text without user interaction. +--- As tabstops, each one can have a placeholder which is used if variable is +--- not defined. There is a special set of variables describing editor state. +--- +--- Example: `V1=$TM_FILENAME V2=${NOTDEFINED:placeholder}` is expanded as +--- `V1=current-file-basename V2=placeholder`. +--- +--- What's different from LSP specification: +--- - Special set of variables is wider and is taken from VSCode specification: +--- https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables +--- Exceptions are `BLOCK_COMMENT_START` and `BLOCK_COMMENT_END` as Neovim doesn't +--- provide this information. +--- - Variable `TM_SELECTED_TEXT` is resolved as contents of |quote_quote| register. +--- It assumes that text is put there prior to expanding. For example, visually +--- select, press |c|, type prefix, and expand. +--- - Environment variables are recognized and supported: `V1=$VIMRUNTIME` will +--- use an actual value of |$VIMRUNTIME|. +--- - Variable transformations are not supported during snippet session. It would +--- require interacting with ECMAScript-like regular expressions for which there +--- is no easy way in Neovim. It may change in the future. +--- Transformations are recognized during parsing, though, with some exceptions: +--- - The `}` inside `if` of `${1:?if:else}` needs escaping (for technical reasons). +--- +--- There is a |MiniSnippets.parse()| function for programmatically parsing +--- snippet body into a comprehensible data structure. +--- +--- # Expand ~ +--- +--- Using snippets is done via what is called "expanding". It goes like this: +--- - Type snippet prefix or its recognizable part. +--- - Press to expand. It will perform the following steps: +--- - Prepare available snippets in current context (buffer + local language). +--- This allows having general function loaders in snippet setup. +--- - Match text to the left of cursor with available prefixes. It first tries +--- to do exact match and falls back to fuzzy matching. +--- - If there are several matches, use `vim.ui.select()` to choose one. +--- - Insert single matching snippet. If snippet contains tabstops, start +--- snippet session. +--- +--- For more details about each step see: +--- - |MiniSnippets.default_prepare()| +--- - |MiniSnippets.default_match()| +--- - |MiniSnippets.default_select()| +--- - |MiniSnippets.default_insert()| +--- +--- Snippet session allows interactive editing at tabstop locations: +--- +--- - All tabstop locations are visualized depending on tabstop "state" (whether +--- it is current/visited/unvisited/final and whether it was already edited). +--- Empty tabstops are visualized with inline virtual text ("•"/"∎" for +--- regular/final tabstops). It is removed after session is stopped. +--- +--- - Start session at first tabstop. Type text to replace placeholder. +--- When finished with current tabstop, jump to next with . Repeat. +--- If changed mind about some previous tabstop, jump back with . +--- Jumping also wraps around the edge (first tabstop is next after final). +--- +--- - Starting another snippet session while there is one active is allowed. +--- This creates nested sessions: current is suspended, new one is started. +--- After newly created is stopped, the suspended one is resumed. +--- +--- - Stop session manually by pressing or it will be done automatically: +--- either by making text edit or exiting in Normal mode when final tabstop is +--- current. If snippet doesn't explicitly define final tabstop, it is added at +--- the end of the snippet. +--- +--- For more details about snippet session see |MiniSnippets-session|. +--- +--- # Management ~ +--- +--- Out of the box 'mini.snippets' doesn't load any snippets, it should be done +--- explicitly inside |MiniSnippets.setup()| following |MiniSnippets.config|. +--- +--- The suggested approach to snippet management is to create dedicated files with +--- snippet data and load them through function loaders in `config.snippets`. +--- See |MiniSnippets-examples| for basic (yet capable) snippet management config. +--- +--- *MiniSnippets-file-specification* +--- General idea of supported files is to have at least out of the box experience +--- with common snippet collections. Namely "rafamadriz/friendly-snippets". +--- The following files are supported: +--- +--- - Extensions: +--- - Read/decoded as JSON object (|vim.json.decode()|): `*.json`, `*.code-snippets` +--- - Executed as Lua file (|dofile()|) and uses returned value: `*.lua` +--- +--- - Content: +--- - Dict-like: object in JSON; returned table in Lua; no order guarantees. +--- - Array-like: array in JSON; returned array table in Lua; preserves order. +--- +--- Example of file content with a single snippet: +--- - Lua dict-like: `return { name = { prefix = 'l', body = 'local $1 = $0' } }` +--- - Lua array-like: `return { { prefix = 'l', body = 'local $1 = $0' } }` +--- - JSON dict-like: `{ "name": { "prefix": "l", "body": "local $1 = $0" } }` +--- - JSON array-like: `[ { "prefix": "l", "body": "local $1 = $0" } ]` +--- +--- General advice: +--- - Put files in "snippets" subdirectory of any path in 'runtimepath' (like +--- "$XDG_CONFIG_HOME/nvim/snippets/global.json"). +--- This is compatible with |MiniSnippets.gen_loader.from_runtime()|. +--- - Prefer `*.json` files with dict-like content if you want more cross-platfrom +--- setup. Otherwise use `*.lua` files with array-like content. +--- +--- Notes: +--- - There is no built-in support for VSCode-like "package.json" files. Define +--- structure manually in |MiniSnippets.setup()| via built-in or custom loaders. +--- - There is no built-in support for `scope` field of snippet data. Snippets are +--- expected to be manually separated into smaller files and loaded on demand. +--- +--- For supported snippet syntax see |MiniSnippets-syntax-specification|. +--- +--- # Demo ~ +--- +--- The best way to grasp the design of snippet management and expansion is to +--- try them out yourself. Here are steps for a basic demo: +--- - Create 'snippets/global.json' file in the config directory with the content: > +--- +--- { +--- "Basic": { "prefix": "ba", "body": "T1=$1 T2=$2 T0=$0" }, +--- "Placeholders": { "prefix": "pl", "body": "T1=${1:aa}\nT2=${2:<$1>}" }, +--- "Choices": { "prefix": "ch", "body": "T1=${1|a,b|} T2=${2|c,d|}" }, +--- "Linked": { "prefix": "li", "body": "T1=$1\nT1=$1" }, +--- "Variables": { "prefix": "va", "body": "Runtime: $VIMRUNTIME\n" }, +--- "Complex": { +--- "prefix": "co", +--- "body": [ "T2=${2:$RANDOM}", "T1=${1:<$2>}", "T2=$2", "T1=$1" ] +--- } +--- } +--- < +--- - Set up 'mini.snippets' as recommended in |MiniSnippets-examples|. +--- - Open Neovim. Type each snippet prefix and press (even if there is +--- still active session). Explore from there. +--- +---@tag MiniSnippets-overview + +--- # Basic snippet management config ~ +--- +--- Example of snippet management setup that should cover most cases: >lua +--- +--- -- Setup +--- local gen_loader = require('mini.snippets').gen_loader +--- require('mini.snippets').setup({ +--- snippets = { +--- -- Load custom file with global snippets first +--- gen_loader.from_file('~/.config/nvim/snippets/global.json'), +--- +--- -- Load snippets based on current language by reading files from +--- -- "snippets/" subdirectories from 'runtimepath' directories. +--- gen_loader.from_lang(), +--- }, +--- }) +--- < +--- This setup allows having single file with custom "global" snippets (will be +--- present in every buffer) and snippets which will be loaded based on the local +--- language (see |MiniSnippets.gen_loader.from_lang()|). +--- +--- Create language snippets manually (by creating and populating +--- '$XDG_CONFIG_HOME/nvim/snippets/lua.json' file) or by installing dedicated +--- snippet collection plugin (like 'rafamadriz/friendly-snippets'). +--- +--- # Select from all available snippets in current context ~ +--- +--- With |MiniSnippets.default_match()|, expand snippets ( by default) at line +--- start or after whitespace. To be able to always select from all current +--- context snippets, make mapping similar to the following: >lua +--- +--- local rhs = function() MiniSnippets.expand({ match = false }) end +--- vim.keymap.set('i', '', rhs, { desc = 'Expand all' }) +--- < +--- # "Supertab"-like / mappings ~ +--- +--- This module intentionally by default uses separate keys to expand and jump as +--- it enables cleaner use of nested sessions. Here is an example of setting up +--- custom to "expand or jump" and to "jump to previous": >lua +--- +--- local snippets = require('mini.snippets') +--- local match_strict = function(snippets) +--- -- Do not match with whitespace to cursor's left +--- return snippets.default_match(snippets, { pattern_fuzzy = '%S+' }) +--- end +--- snippets.setup({ +--- -- ... Set up snippets ... +--- mappings = { expand = '', jump_next = '', jump_prev = '' }, +--- expand = { match = match_strict }, +--- }) +--- local expand_or_jump = function() +--- local can_expand = #MiniSnippets.expand({ insert = false }) > 0 +--- if can_expand then vim.schedule(MiniSnippets.expand); return '' end +--- local is_active = MiniSnippets.session.get() ~= nil +--- if is_active then MiniSnippets.session.jump('next'); return '' end +--- return '\t' +--- end +--- local jump_prev = function() MiniSnippets.session.jump('prev') end +--- vim.keymap.set('i', '', expand_or_jump, { expr = true }) +--- vim.keymap.set('i', '', jump_prev) +--- < +--- # Stop session immediately after jumping to final tabstop ~ +--- +--- Utilize a dedicated |MiniSnippets-events|: >lua +--- +--- local fin_stop = function(args) +--- if args.data.tabstop_to == '0' then MiniSnippets.session.stop() end +--- end +--- local au_opts = { pattern = 'MiniSnippetsSessionJump', callback = fin_stop } +--- vim.api.nvim_create_autocmd('User', au_opts) +--- < +--- # Using Neovim's built-ins to insert snippet ~ +--- +--- Define custom `expand.insert` in |MiniSnippets.config| and mappings: >lua +--- +--- require('mini.snippets').setup({ +--- -- ... Set up snippets ... +--- expand = { +--- insert = function(snippet, _) vim.snippet.expand(snippet.body) end +--- } +--- }) +--- -- Make jump mappings or skip to use built-in / in Neovim>=0.11 +--- local jump_next = function() +--- if vim.snippet.active({direction = 1}) then return vim.snippet.jump(1) end +--- end +--- local jump_prev = function() +--- if vim.snippet.active({direction = -1}) then vim.snippet.jump(-1) end +--- end +--- vim.keymap.set({ 'i', 's' }, '', jump_next) +--- vim.keymap.set({ 'i', 's' }, '', jump_prev) +--- < +--- # Using 'mini.snippets' in other plugins ~ +--- +--- Plugins which want to start 'mini.snippets' session given a snippet body +--- (similar to |vim.snippet.expand()|) are recommended to use the following: >lua +--- +--- -- Check that `MiniSnippets` is set up by the user +--- if MiniSnippets ~= nil then +--- -- Use configured `insert` method with falling back to default +--- local insert = MiniSnippets.config.expand.insert +--- or MiniSnippets.default_insert +--- -- Insert at cursor +--- insert({ body = snippet }) +--- end +--- < +---@tag MiniSnippets-examples + +---@alias __minisnippets_cache_opt `(boolean)` - whether to use cached output. Default: `true`. +---@alias __minisnippets_silent_opt `(boolean)` - whether to hide non-error messages. Default: `false`. +---@alias __minisnippets_loader_return function Snippet loader. + +---@diagnostic disable:undefined-field +---@diagnostic disable:discard-returns +---@diagnostic disable:unused-local + +-- Module definition ========================================================== +local MiniSnippets = {} +local H = {} + +--- Module setup +--- +---@param config table|nil Module config table. See |MiniSnippets.config|. +--- +---@usage >lua +--- require('mini.snippets').setup({}) -- replace {} with your config table +--- -- needs `snippets` field present +--- < +MiniSnippets.setup = function(config) + -- Export module + _G.MiniSnippets = MiniSnippets + + -- Setup config + config = H.setup_config(config) + + -- Apply config + H.apply_config(config) + + -- Define behavior + H.create_autocommands() + + -- Create default highlighting + H.create_default_hl() +end + +--- Module config +--- +--- Default values: +---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section) +---@text # Loaded snippets ~ +--- +--- `config.snippets` is an array containing snippet data which can be: snippet +--- table, function loader, or (however deeply nested) array of snippet data. +--- +--- Snippet is a table with the following fields: +--- +--- - `(string|table|nil)` - string used to match against current text. +--- If array, all strings should be used as separate prefixes. +--- - `(string|table|nil)` - content of a snippet which should follow +--- the |MiniSnippets-syntax-specification|. Array is concatenated with "\n". +--- - `(string|table|nil)` - description of snippet. Can be used to display +--- snippets in a more human readable form. Array is concatenated with "\n". +--- +--- Function loaders are expected to be called with single `context` table argument +--- (containing any data about current context) and return same as `config.snippets` +--- data structure. +--- +--- `config.snippets` is resolved with `config.prepare` on every expand. +--- See |MiniSnippets.default_prepare()| for how it is done by default. +--- +--- For a practical example see |MiniSnippets-examples|. +--- Here is an illustration of `config.snippets` customization capabilities: >lua +--- +--- local gen_loader = require('mini.snippets').gen_loader +--- require('mini.snippets').setup({ +--- snippets = { +--- -- Load custom file with global snippets first (order matters) +--- gen_loader.from_file('~/.config/nvim/snippets/global.json'), +--- +--- -- Or add them here explicitly +--- { prefix='cdate', body='$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE' }, +--- +--- -- Load snippets based on current language by reading files from +--- -- "snippets/" subdirectories from 'runtimepath' directories. +--- gen_loader.from_lang(), +--- +--- -- Load project-local snippets with `gen_loader.from_file()` +--- -- and relative path (file doesn't have to be present) +--- gen_loader.from_file('.vscode/project.code-snippets'), +--- +--- -- Custom loader for language-specific project-local snippets +--- function(context) +--- local rel_path = '.vscode/' .. context.lang .. '.code-snippets' +--- if vim.fn.filereadable(rel_path) == 0 then return end +--- return MiniSnippets.read_file(rel_path) +--- end, +--- +--- -- Ensure that some prefixes are not used (as there is no `body`) +--- { prefix = { 'bad', 'prefix' } }, +--- } +--- }) +--- < +--- # Mappings ~ +--- +--- `config.mappings` describes which mappings are automatically created. +--- +--- `mappings.expand` is created globally in Insert mode and is used to expand +--- snippet at cursor. Use |MiniSnippets.expand()| for custom mappings. +--- +--- `mappings.jump_next`, `mappings.jump_prev`, and `mappings.stop` are created for +--- the duration of active snippet session(s) from |MiniSnippets.default_insert()|. +--- Used to jump to next/previous tabstop and stop active session respectively. +--- Use |MiniSnippets.session.jump()| and |MiniSnippets.session.stop()| for custom +--- Insert mode mappings. +--- +--- # Expand ~ +--- +--- `config.expand` defines expand steps (see |MiniSnippets-glossary|), either after +--- pressing `mappings.expand` or starting manually via |MiniSnippets.expand()|. +--- +--- `expand.prepare` is a function that takes `raw_snippets` in the form of +--- `config.snippets` and should return a plain array of snippets (as described +--- in |MiniSnippets-glossary|). Will be called on every |MiniSnippets.expand()| call. +--- If returns second value, it will be used as context for warning messages. +--- Default: |MiniSnippets.default_prepare()|. +--- +--- `expand.match` is a function that takes `expand.prepare` output and returns +--- an array of matched snippets: one or several snippets user might intend to +--- eventually insert. Should sort matches in output from best to worst. +--- Entries can contain `region` field with current buffer region used to do +--- the match; usually it needs to be removed (similar to how |ins-completion| +--- and |abbreviations| work). +--- Default: |MiniSnippets.default_match()| +--- +--- `expand.select` is a function that takes output of `expand.match` and function +--- that inserts snippet (and also ensures Insert mode and removes snippet's match +--- region). Should allow user to perform interactive snippet selection and +--- insert the chosen one. Designed to be compatible with |vim.ui.select()|. +--- Called for any non-empty `expand.match` output (even with single entry). +--- Default: |MiniSnippets.default_select()| +--- +--- `expand.insert` is a function that takes single snippet table as input and +--- inserts snippet at cursor position. This is a main entry point for adding +--- text template to buffer and starting a snippet session. +--- If called inside |MiniSnippets.expand()| (which is a usual interactive case), +--- all it has to do is insert snippet at cursor position. Ensuring Insert mode +--- and removing matched snippet region is done beforehand. +--- Default: |MiniSnippets.default_insert()| +--- +--- Illustration of `config.expand` customization: >lua +--- +--- -- Supply extra data as context +--- local my_p = function(raw_snippets) +--- local _, cont = MiniSnippets.default_prepare({}) +--- cont.cursor = vim.api.nvim_win_get_cursor() +--- return MiniSnippets.default_prepare(raw_snippets, { context = cont }) +--- end +--- -- Perform fuzzy match based only on alphanumeric characters +--- local my_m = function(snippets, pos) +--- return MiniSnippets.default_match(snippets, pos, {pattern_fuzzy = '%w*'}) +--- end +--- -- Always insert the best matched snippet +--- local my_s = function(snippets, insert) return insert(snippets[1]) end +--- -- Use different string to show empty tabstop as inline virtual text +--- local my_i = function(snippet) +--- return MiniSnippets.default_insert(snippet, { empty_tabstop = '$' }) +--- end +--- +--- require('mini.snippets').setup({ +--- -- ... Set up snippets ... +--- expand = { prepare = my_p, match = my_m, select = my_s, insert = my_i } +--- }) +--- < +MiniSnippets.config = { + -- Array of snippets and loaders (see |MiniSnippets.config| for details). + -- Nothing is defined by default. Add manually to have snippets to match. + snippets = {}, + + -- Module mappings. Use `''` (empty string) to disable one. + mappings = { + -- Expand snippet at cursor position. Created globally in Insert mode. + expand = '', + + -- Interact with default `expand.insert` session. + -- Created for the duration of active session(s) + jump_next = '', + jump_prev = '', + stop = '', + }, + + -- Functions describing snippet expansion. If `nil`, default values + -- are `MiniSnippets.default_()`. + expand = { + -- Resolve raw config snippets at context + prepare = nil, + -- Match resolved snippets at cursor position + match = nil, + -- Possibly choose among matched snippets + select = nil, + -- Insert selected snippet + insert = nil, + }, +} +--minidoc_afterlines_end + +--- Expand snippet at cursor position +--- +--- Perform expand steps (see |MiniSnippets-glossary|). +--- Initial raw snippets are taken from `config.snippets` in current buffer. +--- Snippets from `vim.b.minisnippets_config` are appended to global snippet array. +--- +---@param opts table|nil Options. Same structure as `expand` in |MiniSnippets.config| +--- and uses its values as default. There are differences in allowed values: +--- - Use `match = false` to have all buffer snippets as matches. +--- - Use `select = false` to always expand the best match (if any). +--- - Use `insert = false` to return all matches without inserting. +--- +--- Note: `opts.insert` is called after ensuring Insert mode, removing snippet's +--- match region, and positioning cursor. +--- +---@return table|nil If `insert` is `false`, an array of matched snippets (`expand.match` +--- output). Otherwise `nil`. +--- +---@usage >lua +--- -- Match, maybe select, and insert +--- MiniSnippets.expand() +--- +--- -- Match and force expand the best match (if any) +--- MiniSnippets.expand({ select = false }) +--- +--- -- Use all current context snippets as matches +--- MiniSnippets.expand({ match = false }) +--- +--- -- Get all matched snippets +--- local matches = MiniSnippets.expand({ insert = false }) +--- +--- -- Get all current context snippets +--- local all = MiniSnippets.expand({ match = false, insert = false }) +--- < +MiniSnippets.expand = function(opts) + if H.is_disabled() then return end + local config = H.get_config() + opts = vim.tbl_extend('force', config.expand, opts or {}) + + -- Validate + local prepare = opts.prepare or MiniSnippets.default_prepare + if not vim.is_callable(prepare) then H.error('`opts.prepare` should be callable') end + + local match = false + if opts.match ~= false then match = opts.match or MiniSnippets.default_match end + if not (match == false or vim.is_callable(match)) then H.error('`opts.match` should be `false` or callable') end + + local select = false + if opts.select ~= false then select = opts.select or MiniSnippets.default_select end + if not (select == false or vim.is_callable(select)) then H.error('`opts.select` should be `false` or callable') end + + local insert = false + if opts.insert ~= false then insert = opts.insert or MiniSnippets.default_insert end + if not (insert == false or vim.is_callable(insert)) then H.error('`opts.insert` should be `false` or callable') end + + -- Match + local all_snippets, context = prepare(config.snippets) + if not H.is_array_of(all_snippets, H.is_snippet) then H.error('`prepare` should return array of snippets') end + local matches = match == false and all_snippets or match(all_snippets) + if not H.is_array_of(matches, H.is_snippet) then H.error('`match` should return array of snippets') end + + -- Act + if insert == false then return matches end + if #all_snippets == 0 then return H.notify('No snippets in context:\n' .. vim.inspect(context), 'WARN') end + if #matches == 0 then return H.notify('No matches in context:\n' .. vim.inspect(context), 'WARN') end + + local insert_ext = H.make_extended_insert(insert) + + if select == false then return insert_ext(matches[1]) end + select(matches, insert_ext) +end + +--- Generate snippet loader +--- +--- This is a table with function elements. Call to actually get a loader. +--- +--- Common features for all produced loaders: +--- - Designed to work with |MiniSnippets-file-specification|. +--- - Cache output by default, i.e. second and later calls with same input value +--- don't read file system. Different loaders from same generator share cache. +--- Disable by setting `opts.cache` to `false`. +--- To clear all cache, call |MiniSnippets.setup()|. For example: +--- `MiniSnippets.setup(MiniSnippets.config)` +--- - Use |vim.notify()| to show problems during loading while trying to load as +--- much correctly defined snippet data as possible. +--- Disable by setting `opts.silent` to `true`. +MiniSnippets.gen_loader = {} + +--- Generate language loader +--- +--- Output loads files from "snippets/" subdirectories of 'runtimepath' matching +--- configured language patterns. +--- See |MiniSnippets.gen_loader.from_runtime()| for runtime loading details. +--- +--- Language is taken from field (if present with string value) of `context` +--- argument used in loader calls during "prepare" stage. +--- This is compatible with |MiniSnippets.default_prepare()| and most snippet +--- collection plugins. +--- +---@param opts table|nil Options. Possible values: +--- - `(table)` - map from language to array of runtime patterns +--- used to find snippet files, as in |MiniSnippets.gen_loader.from_runtime()|. +--- Patterns will be processed in order. With |MiniSnippets.default_prepare()| +--- it means if snippets have same prefix, data from later patterns is used. +--- +--- Default pattern array (for non-empty language) is constructed as to read +--- `*.json` and `*.lua` files that are: +--- - Inside "snippets/" subdirectory named as language (files can be however +--- deeply nested). +--- - Named as language and is in "snippets/" directory (however deep). +--- Example for "lua" language: >lua +--- { 'lua/**/*.json', 'lua/**/*.lua', '**/lua.json', '**/lua.lua' } +--- < +--- Add entry for `""` (empty string) as language to be sourced when `lang` +--- context is empty string (which is usually temporary scratch buffers). +--- +--- - __minisnippets_cache_opt +--- Note: caching is done per used runtime pattern, not `lang` value to allow +--- different `from_lang()` loaders to share cache. +--- - __minisnippets_silent_opt +--- +---@return __minisnippets_loader_return +--- +---@usage >lua +--- -- Adjust language patterns +--- local latex_patterns = { 'latex/**/*.json', '**/latex.json' } +--- local lang_patterns = { tex = latex_patterns, plaintex = latex_patterns } +--- local gen_loader = require('mini.snippets').gen_loader +--- require('mini.snippets').setup({ +--- snippets = { +--- gen_loader.from_lang({ lang_patterns = lang_patterns }), +--- }, +--- }) +--- < +MiniSnippets.gen_loader.from_lang = function(opts) + opts = vim.tbl_extend('force', { lang_patterns = {}, cache = true, silent = false }, opts or {}) + for lang, tbl in pairs(opts.lang_patterns) do + if type(lang) ~= 'string' then H.error('Keys of `opts.lang_patterns` should be string language names') end + if not H.is_array_of(tbl, H.is_string) then H.error('Values of `opts.lang_patterns` should be string arrays') end + end + + local loaders, loader_opts = {}, { cache = opts.cache, silent = opts.silent } + + return function(context) + local lang = (context or {}).lang + if type(lang) ~= 'string' then return {} end + + local patterns = opts.lang_patterns[lang] + if patterns == nil and lang == '' then return {} end + -- NOTE: Don't use `{json,lua}` for better compatibility, as it seems that + -- its support might depend on the shell (and might not work on Windows). + -- Which is shame because fewer patterns used mean fewer calls to cache. + patterns = patterns + or { lang .. '/**/*.json', lang .. '/**/*.lua', '**/' .. lang .. '.json', '**/' .. lang .. '.lua' } + + local res = {} + for _, pat in ipairs(patterns) do + local loader = loaders[pat] or MiniSnippets.gen_loader.from_runtime(pat, loader_opts) + loaders[pat] = loader + table.insert(res, loader(context)) + end + return res + end +end + +--- Generate runtime loader +--- +--- Output loads files which match `pattern` inside "snippets/" directories from +--- 'runtimepath'. This is useful to simultaneously read several similarly +--- named files from different sources. Order from 'runtimepath' is preserved. +--- +--- Typical case is loading snippets for a language from files like `xxx.{json,lua}` +--- but located in different "snippets/" directories inside 'runtimepath'. +--- - ``/snippets/lua.json - manually curated snippets in user config. +--- - ``/snippets/lua.json - from installed plugin. +--- - ``/after/snippets/lua.json - used to adjust snippets from plugins. +--- For example, remove some snippets by using prefixes and no body. +--- +---@param pattern string Pattern of files to read. Can have wildcards as described +--- in |nvim_get_runtime_file()|. Example for "lua" language: `'lua.{json,lua}'`. +---@param opts table|nil Options. Possible fields: +--- - `(boolean)` - whether to load from all matching runtime files. +--- Default: `true`. +--- - __minisnippets_cache_opt +--- Note: caching is done per `pattern` value, which assumes that both +--- 'runtimepath' value and snippet files do not change during Neovim session. +--- Caching this way gives significant speed improvement by reducing the need +--- to traverse file system on every snippet expand. +--- - __minisnippets_silent_opt +--- +---@return __minisnippets_loader_return +MiniSnippets.gen_loader.from_runtime = function(pattern, opts) + if type(pattern) ~= 'string' then H.error('`pattern` should be string') end + opts = vim.tbl_extend('force', { all = true, cache = true, silent = false }, opts or {}) + + pattern = 'snippets/' .. pattern + local cache, read_opts = opts.cache, { cache = opts.cache, silent = opts.silent } + local read = function(p) return MiniSnippets.read_file(p, read_opts) end + return function() + if cache and H.cache.runtime[pattern] ~= nil then return vim.deepcopy(H.cache.runtime[pattern]) end + + local res = vim.tbl_map(read, vim.api.nvim_get_runtime_file(pattern, opts.all)) + if cache then H.cache.runtime[pattern] = vim.deepcopy(res) end + return res + end +end + +--- Generate single file loader +--- +--- Output is a thin wrapper around |MiniSnippets.read_file()| which will skip +--- warning if file is absent (other messages are still shown). Use it to load +--- file which is not guaranteed to exist (like project-local snippets). +--- +---@param path string Same as in |MiniSnippets.read_file()|. +---@param opts table|nil Same as in |MiniSnippets.read_file()|. +--- +---@return __minisnippets_loader_return +MiniSnippets.gen_loader.from_file = function(path, opts) + if type(path) ~= 'string' then H.error('`path` should be string') end + opts = vim.tbl_extend('force', { cache = true, silent = false }, opts or {}) + + return function() + local full_path = vim.fn.fnamemodify(path, ':p') + if vim.fn.filereadable(full_path) ~= 1 then return {} end + return MiniSnippets.read_file(full_path, opts) or {} + end +end + +--- Read file with snippet data +--- +---@param path string Path to file with snippets. Can be relative. +--- See |MiniSnippets-file-specification| for supported file formats. +---@param opts table|nil Options. Possible fields: +--- - __minisnippets_cache_opt +--- Note: Caching is done per full path only after successful reading. +--- - __minisnippets_silent_opt +--- +---@return table|nil Array of snippets or `nil` if failed (also warn with |vim.notify()| +--- about the reason). +MiniSnippets.read_file = function(path, opts) + if type(path) ~= 'string' then H.error('`path` should be string') end + opts = vim.tbl_extend('force', { cache = true, silent = false }, opts or {}) + + path = vim.fn.fnamemodify(path, ':p') + local problem_prefix = 'There were problems reading file ' .. path .. ':\n' + if opts.cache and H.cache.file[path] ~= nil then return vim.deepcopy(H.cache.file[path]) end + + if vim.fn.filereadable(path) ~= 1 then + return H.notify(problem_prefix .. 'File is absent or not readable', 'WARN', opts.silent) + end + local ext = path:match('%.([^%.]+)$') + if ext == nil or not (ext == 'lua' or ext == 'json' or ext == 'code-snippets') then + return H.notify(problem_prefix .. 'Extension is not supported', 'WARN', opts.silent) + end + + local res = H.file_readers[ext](path, opts.silent) + + -- Notify about problems but still cache if there are read snippets + local prob = table.concat(res.problems, '\n') + if prob ~= '' then H.notify(problem_prefix .. prob, 'WARN', opts.silent) end + + if res.snippets == nil then return nil end + if opts.cache then H.cache.file[path] = vim.deepcopy(res.snippets) end + return res.snippets +end + +--- Default prepare +--- +--- Normalize raw snippets (as in `snippets` from |MiniSnippets.config|) based on +--- supplied context: +--- - Traverse and flatten nested arrays. Function loaders are executed with +--- `opts.context` as argument and output is processed recursively. +--- - Ensure unique non-empty prefixes: later ones completely override earlier +--- ones (similar to how |ftplugin| and similar runtime design behave). +--- Empty string prefixes are all added (to allow inserting without matching). +--- - Transform and infer fields: +--- - Multiply array `prefix` into several snippets with same body/description. +--- Infer absent `prefix` as empty string. +--- - Concatenate array `body` with "\n". Do not infer absent `body` to have +--- it remove previously added snippet with the same prefix. +--- - Concatenate array `desc` with "\n". Infer `desc` field from `description` +--- (for compatibility) or `body` fields, in that order. +--- - Sort output by prefix. +--- +--- Unlike |MiniSnippets.gen_loader| entries, there is no output caching. This +--- avoids duplicating data from `gen_loader` cache and reduces memory usage. +--- It also means that every |MiniSnippets.expand()| call prepares snippets, which +--- is usually fast enough. If not, consider manual caching: >lua +--- +--- local cache = {} +--- local prepare_cached = function(raw_snippets) +--- local _, cont = MiniSnippets.default_prepare({}) +--- local id = 'buf=' .. cont.buf_id .. ',lang=' .. cont.lang +--- if cache[id] then return unpack(vim.deepcopy(cache[id])) end +--- local snippets = MiniSnippets.default_prepare(raw_snippets) +--- cache[id] = vim.deepcopy({ snippets, cont }) +--- return snippets, cont +--- end +--- < +---@param raw_snippets table Array of snippet data as from |MiniSnippets.config|. +---@param opts table|nil Options. Possible fields: +--- - `(any)` - Context used as an argument for callable snippet data. +--- Default: table with (current buffer identifier) and (local +--- language) fields. Language is computed from tree-sitter parser at cursor +--- (allows different snippets in injected languages), 'filetype' otherwise. +--- +---@return ... Array of snippets and supplied context (default if none was supplied). +MiniSnippets.default_prepare = function(raw_snippets, opts) + if not H.islist(raw_snippets) then H.error('`raw_snippets` should be array') end + opts = vim.tbl_extend('force', { context = nil }, opts or {}) + local context = opts.context + if context == nil then context = H.get_default_context() end + + -- Traverse snippets to have unique non-empty prefixes + local res = {} + H.traverse_raw_snippets(raw_snippets, res, context) + + -- Convert to array ordered by prefix + res = vim.tbl_values(res) + table.sort(res, function(a, b) return a.prefix < b.prefix end) + return res, context +end + +--- Default match +--- +--- Match snippets based on the line before cursor. +--- +--- Tries two matching approaches consecutively: +--- - Find exact snippet prefix (if present and non-empty) to the left of cursor. +--- It should also be preceded with a byte that matches `pattern_exact_boundary`. +--- In case of any match, return the one with the longest prefix. +--- - Match fuzzily snippet prefixes against the base (text to the left of cursor +--- extracted via `opts.pattern_fuzzy`). Matching is done via |matchfuzzy()|. +--- Empty base results in all snippets being matched. Return all fuzzy matches. +--- +---@param snippets table Array of snippets which can be matched. +---@param opts table|nil Options. Possible fields: +--- - `(string)` - Lua pattern for the byte to the left +--- of exact match to accept it. Line start is matched against empty string; +--- use `?` quantifier to allow it as boundary. +--- Default: `[%s%p]?` (accept only whitespace and punctuation as boundary, +--- allow match at line start). +--- Example: prefix "l" matches in lines `l`, `_l`, `x l`; but not `1l`, `ll`. +--- - `(string)` - Lua pattern to extract base to the left of +--- cursor for fuzzy matching. Supply empty string to skip this step. +--- Default: `'%S*'` (as many as possible non-whitespace; allow empty string). +--- +---@return table Array of matched snippets ordered from best to worst match. +--- +---@usage >lua +--- -- Accept any exact match +--- MiniSnippets.default_match(snippets, { pattern_exact_boundary = '.?' }) +--- +--- -- Perform fuzzy match based only on alphanumeric characters +--- MiniSnippets.default_match(snippets, { pattern_fuzzy = '%w*' }) +--- < +MiniSnippets.default_match = function(snippets, opts) + if not H.is_array_of(snippets, H.is_snippet) then H.error('`snippets` should be array of snippets') end + opts = vim.tbl_extend('force', { pattern_exact_boundary = '[%s%p]?', pattern_fuzzy = '%S*' }, opts or {}) + if not H.is_string(opts.pattern_exact_boundary) then H.error('`opts.pattern_exact_boundary` should be string') end + + -- Compute line before cursor. Treat Insert mode as exclusive for right edge. + local lnum, col = vim.fn.line('.'), vim.fn.col('.') + local to = col - (vim.fn.mode() == 'i' and 1 or 0) + local line = vim.fn.getline(lnum):sub(1, to) + + -- Exact. Use 0 as initial best match width to not match empty prefixes. + local best_id, best_match_width = nil, 0 + local pattern_boundary = '^' .. opts.pattern_exact_boundary .. '$' + for i, s in pairs(snippets) do + local w = (s.prefix or ''):len() + if best_match_width < w and line:sub(-w) == s.prefix and line:sub(-w - 1, -w - 1):find(pattern_boundary) then + best_id, best_match_width = i, w + end + end + if best_id ~= nil then + local res = vim.deepcopy(snippets[best_id]) + res.region = { from = { line = lnum, col = to - best_match_width + 1 }, to = { line = lnum, col = to } } + return { res } + end + + -- Fuzzy + if not H.is_string(opts.pattern_fuzzy) then H.error('`opts.pattern_fuzzy` should be string') end + if opts.pattern_fuzzy == '' then return {} end + + local base = string.match(line, opts.pattern_fuzzy .. '$') + if base == nil then return {} end + if base == '' then return vim.deepcopy(snippets) end + + local snippets_with_prefix = vim.tbl_filter(function(s) return s.prefix ~= nil end, snippets) + local fuzzy_matches = vim.fn.matchfuzzy(snippets_with_prefix, base, { key = 'prefix' }) + local from_col = to - base:len() + 1 + for _, s in ipairs(fuzzy_matches) do + s.region = { from = { line = lnum, col = from_col }, to = { line = lnum, col = to } } + end + + return fuzzy_matches +end + +--- Default select +--- +--- Show snippets as |vim.ui.select()| items and insert the chosen one. +--- For best interactive experience requires `vim.ui.select()` to work from Insert +--- mode (be properly called and restore Insert mode after choice). +--- This is the case for at least |MiniPick.ui_select()| and Neovim's default. +--- +---@param snippets table Array of snippets (as an output of `config.expand.match`). +---@param insert function|nil Function to insert chosen snippet (passed as the only +--- argument). Expected to remove snippet's match region (if present as a field) +--- and ensure proper cursor position in Insert mode. +--- Default: |MiniSnippets.default_insert()|. +---@param opts table|nil Options. Possible fields: +--- - `(boolean)` - whether to skip |vim.ui.select()| for `snippets` +--- with a single entry and insert it directly. Default: `true`. +MiniSnippets.default_select = function(snippets, insert, opts) + if not H.is_array_of(snippets, H.is_snippet) then H.error('`snippets` should be an array of snippets') end + if #snippets == 0 then return H.notify('No snippets to select from', 'WARN') end + insert = insert or MiniSnippets.default_insert + if not vim.is_callable(insert) then H.error('`insert` should be callable') end + opts = opts or {} + + if #snippets == 1 and (opts.insert_single == nil or opts.insert_single == true) then + insert(snippets[1]) + return + end + + -- Format + local prefix_width = 0 + for i, s in ipairs(snippets) do + local prefix = s.prefix or '' + prefix_width = math.max(prefix_width, vim.fn.strdisplaywidth(prefix)) + end + local format_item = function(s) + local prefix, desc = s.prefix or '', s.desc or s.description or '' + local pad = string.rep(' ', prefix_width - vim.fn.strdisplaywidth(prefix)) + return prefix .. pad .. ' │ ' .. desc + end + + -- Schedule insert to allow `vim.ui.select` override to restore window/cursor + local on_choice = vim.schedule_wrap(function(item, _) insert(item) end) + vim.ui.select(snippets, { prompt = 'Snippets', format_item = format_item }, on_choice) +end + +--- Default insert +--- +--- Prepare for snippet insert and do it: +--- - Ensure Insert mode. +--- - Delete snippet's match region (if present as field). Ensure cursor. +--- - Parse snippet body with |MiniSnippets.parse()| and enabled `normalize`. +--- In particular, evaluate variables, ensure final node presence and same +--- text for nodes with same tabstops. Stop if not able to. +--- - Insert snippet at cursor: +--- - Add snippet's text. Lines are split at "\n". +--- Indent and left comment leaders (inferred from 'commentstring' and +--- 'comments') of current line are repeated on the next. +--- Tabs ("\t") are expanded according to 'expandtab' and 'shiftwidth'. +--- - If there is an actionable tabstop (not final), start snippet session. +--- +--- *MiniSnippets-session* +--- # Session life cycle ~ +--- +--- - Start with cursor at first tabstop. If there are linked tabstops, cursor +--- is placed at start of reference node (see |MiniSnippets-glossary|). +--- All tabstops are visualized with dedicated highlight groups (see "Highlight +--- groups" section in |MiniSnippets|). +--- Empty tabstops are visualized with inline virtual text ("•"/"∎" for +--- regular/final tabstops) meaning that it is not an actual text in the +--- buffer and will be removed after session is stopped. +--- +--- - Decide whether you want to replace the placeholder. If not, jump to next or +--- previous tabstop. If yes, edit it: add new and/or delete already added text. +--- While doing so, several things happen in all linked tabstops (if any): +--- +--- - After first typed character the placeholder is removed and highlighting +--- changes from `MiniSnippetsCurrentReplace` to `MiniSnippetsCurrent`. +--- - Text in all tabstop nodes is synchronized with the reference one. +--- Note: text sync is forced only for current tabstop (for performance). +--- +--- - Jump with / to next / previous tabstop. Exact keys can be +--- adjusted in |MiniSnippets.config| `mappings`. +--- See |MiniSnippets.session.jump()| for jumping details. +--- +--- - Nest another session by expanding snippet in the same way as without +--- active session (can be even done in another buffer). If snippet has no +--- actionable tabstop, text is just inserted. Otherwise start nested session: +--- +--- - Suspend current session: hide highlights, keep text change tracking. +--- - Start new session and act as if it is the only one (edit/jump/nest). +--- - When ready (possibly after even more nested sessions), stop the session. +--- This will resume previous one: sync text for its current tabstop and +--- show highlighting. +--- The experience of text synchronization only after resuming session is +--- similar to how editing in |visual-block| mode works. +--- Nothing else (like cursor/mode/buffer) is changed for a smoother +--- automated session stop. +--- +--- Notes about the choice of the "session stack" approach to nesting over more +--- common "merge into single session" approach: +--- - Does not overload with highlighting. +--- - Allows nested sessions in different buffers. +--- - Doesn't need a complex logic of injecting one session into another. +--- +--- - Repeat edit/jump/nest steps any number of times. +--- +--- - Stop. It can be done in two ways: +--- +--- - Manually by pressing or calling |MiniSnippets.session.stop()|. +--- Exact key can be adjusted in |MiniSnippets.config| `mappings`. +--- - Automatically: any text edit or switching to Normal mode stops session +--- if final tabstop (`$0`) is current. Its presence is ensured after insert. +--- Not stopping session right away after jumping to final mode (as most +--- other snippet plugins do) allows going back to other tabstops in case +--- of a late missed typo. Wrapping around the edge during jumping also +--- helps with that. +--- If current tabstop is not final, exiting into Normal mode for quick edit +--- outside of snippets range (or carefully inside) is fine. Later get back +--- into Insert mode and jump to next tabstop or manually stop session. +--- See |MiniSnippets-examples| for how to set up custom stopping rules. +--- +--- Use |MiniSnippets.session.get()| to get data about active/nested session(s). +--- Use |MiniSnippets.session.jump()| / |MiniSnippets.session.stop()| in mappings. +--- +--- What is allowed but not officially supported/recommended: +--- +--- - Editing text within snippet range but outside of session life cycle. Mostly +--- behaves as expected, but may harm tracking metadata (|extmarks|). +--- In general anything but deleting tabstop range should be OK. +--- Text synchronization of current tabstop would still be active. +--- +--- *MiniSnippets-events* +--- # Events ~ +--- +--- General session activity (autocommand data contains field): +--- - `MiniSnippetsSessionStart` - after a session is started. +--- - `MiniSnippetsSessionStop` - before a session is stopped. +--- +--- Nesting session activity (autocommand data contains field): +--- - `MiniSnippetsSessionSuspend` - before a session is suspended. +--- - `MiniSnippetsSessionResume` - after a session is resumed. +--- +--- Jumping between tabstops (autocommand data contains and +--- fields): +--- - `MiniSnippetsSessionJumpPre` - before jumping to a new tabstop. +--- - `MiniSnippetsSessionJump` - after jumping to a new tabstop. +--- +---@param snippet table Snippet table. Field is mandatory. +---@param opts table|nil Options. Possible fields: +--- - `(string)` - used to visualize empty regular tabstops. +--- Default: "•". +--- - `(string)` - used to visualize empty final tabstop(s). +--- Default: "∎". +--- - `(table)` - passed to |MiniSnippets.parse()|. +--- Default: `{}`. +MiniSnippets.default_insert = function(snippet, opts) + if not H.is_snippet(snippet) then H.error('`snippet` should be a snippet table') end + + local default_opts = { empty_tabstop = '•', empty_tabstop_final = '∎', lookup = {} } + opts = vim.tbl_deep_extend('force', default_opts, opts or {}) + if not H.is_string(opts.empty_tabstop) then H.error('`empty_tabstop` should be string') end + if not H.is_string(opts.empty_tabstop_final) then H.error('`empty_tabstop_final` should be string') end + if type(opts.lookup) ~= 'table' then H.error('`lookup` should be table') end + + local nodes = MiniSnippets.parse(snippet.body, { normalize = true, lookup = opts.lookup }) + + -- Ensure insert in Insert mode (for proper cursor positioning at EOL) + H.call_in_insert_mode(function() + H.delete_region(snippet.region) + H.session_init(H.session_new(nodes, snippet, opts), true) + end) +end + +--- Work with snippet session from |MiniSnippets.default_insert()| +MiniSnippets.session = {} + +--- Get data about active session +--- +---@param all boolean Whether to return array with the whole session stack. +--- Default: `false`. +--- +---@return table Single table with session data (if `all` is `false`) or array of them. +--- Session data contains the following fields: +--- - `(number)` - identifier of session's buffer. +--- - `(string)` - identifier of session's current tabstop. +--- - `(number)` - |extmark| identifier which track session range. +--- - `(table)` - |MiniSnippets.default_insert()| arguments used to +--- create the session. A table with and fields. +--- - `(table)` - parsed array of snippet nodes which is kept up to date +--- during session. Has the structure of a normalized |MiniSnippets.parse()| +--- output, plus every node contains `extmark_id` field with |extmark| identifier +--- which can be used to get data about the current node state. +--- - `(number)` - |namespace| identifier for all session's extmarks. +--- - `(table)` - data about session's tabstops. Fields are string +--- tabstop identifiers and values are tables with the following fields: +--- - `(boolean)` - whether tabstop was visited. +--- - `(string)` - identifier of the next tabstop. +--- - `(string)` - identifier of the previous tabstop. +--- +MiniSnippets.session.get = function(all) return vim.deepcopy(all and H.sessions or H.get_active_session()) end + +--- Jump to next/previous tabstop +--- +--- Make next/previous tabstop be current. Executes the following steps: +--- - Mark current tabstop as visited. +--- - Find the next/previous tabstop id assuming they are sorted as numbers. +--- Tabstop "0" is always last. Search is wrapped around the edges: first and +--- final tabstops are next/previous for one another. +--- - Focus on target tabstop: +--- - Ensure session's buffer is current. +--- - Adjust highlighting of affected nodes. +--- - Set cursor at tabstop's reference node (first node among linked). +--- Cursor is placed on left edge if tabstop has not been edited yet (so +--- typing text replaces placeholder), on right edge otherwise (to update +--- already edited text). +--- - Show relevant choices for tabstop with choices. They are computed by +--- matching to tabstop text: choice is matched if starts with it. +--- Note: if 'completeopt' contains "fuzzy" flag, perform fuzzy matching +--- with |matchfuzzy()|. +--- +---@param direction string One of "next" or "prev". +MiniSnippets.session.jump = function(direction) + if not (direction == 'prev' or direction == 'next') then H.error('`direction` should be one of "prev", "next"') end + H.call_in_insert_mode(function() H.session_jump(H.get_active_session(), direction) end) +end + +--- Stop (only) active session +--- +--- To stop all nested sessions use the following code: >lua +--- +--- while MiniSnippets.session.get() do +--- MiniSnippets.session.stop() +--- end +--- < +MiniSnippets.session.stop = function() + local cur_session = H.get_active_session() + if cur_session == nil then return end + H.session_deinit(cur_session, true) + H.sessions[#H.sessions] = nil + if #H.sessions == 0 then + vim.api.nvim_del_augroup_by_name('MiniSnippetsTrack') + H.unmap_in_sessions() + end + H.session_init(H.get_active_session(), false) +end + +--- Parse snippet +--- +---@param snippet_body string|table Snippet body as string or array of strings. +--- Should follow |MiniSnippets-syntax-specification|. +---@param opts table|nil Options. Possible fields: +--- - `(boolean)` - whether to normalize nodes: +--- - Evaluate variable nodes and add output as a `text` field. +--- If variable is not set, `text` field is `nil`. +--- Values from `opts.lookup` are preferred over evaluation output. +--- See |MiniSnippets-syntax-specification| for more info about variables. +--- - Add `text` field for tabstops present in `opts.lookup`. +--- - Ensure every node contains exactly one of `text` or `placeholder` fields. +--- If there are none, add default `placeholder` (one text node with first +--- choice or empty string). If there are both, remove `placeholder` field. +--- - Ensure present final tabstop: append to end if absent. +--- - Ensure that nodes for same tabstop have same placeholder. Use the one +--- from the first node. +--- Default: `false`. +--- - `(table)` - map from variable/tabstop (string) name to its value. +--- Default: `{}`. +--- +---@return table Array of nodes. Node is a table with fields depending on node type: +--- - Text node: +--- - `(string)` - node's text. +--- - Tabstop node: +--- - `(string)` - tabstop identifier. +--- - `(string|nil)` - tabstop value (if present in ). +--- - `(table|nil)` - array of nodes to be used as placeholder. +--- - `(table|nil)` - array of string choices. +--- - `(table|nil)` - array of transformation string parts. +--- - Variable node: +--- - `(string)` - variable name. +--- - `(string|nil)` - variable value. +--- - `(table|nil)` - array of nodes to be used as placeholder. +--- - `(table|nil)` - array of transformation string parts. +MiniSnippets.parse = function(snippet_body, opts) + if H.is_array_of(snippet_body, H.is_string) then snippet_body = table.concat(snippet_body, '\n') end + if type(snippet_body) ~= 'string' then H.error('Snippet body should be string or array of strings') end + + opts = vim.tbl_extend('force', { normalize = false, lookup = {} }, opts or {}) + + -- Overall idea: implement a state machine which updates on every character. + -- This leads to a bit spaghetti code, but doesn't require `vim.lpeg` DSL + -- knowledge and can provide more information in error messages. + -- Output is array of nodes representing the snippet body. + -- Format is mostly based on grammar in LSP spec 3.18 with small differences. + + -- State table. Each future string is tracked as array and merged later. + --stylua: ignore + local state = { + name = 'text', + -- Node array for depths of currently processed nested placeholders. + -- Depth 1 is the original snippet. + depth_arrays = { { { text = {} } } }, + set_name = function(self, name) self.name = name; return self end, + add_node = function(self, node) table.insert(self.depth_arrays[#self.depth_arrays], node); return self end, + set_in = function(self, node, field, value) node[field] = value; return self end, + is_not_top_level = function(self) return #self.depth_arrays > 1 end, + } + + for i = 0, vim.fn.strchars(snippet_body) - 1 do + -- Infer helper data (for more concise manipulations inside processor) + local depth = #state.depth_arrays + local arr = state.depth_arrays[depth] + local processor, node = H.parse_processors[state.name], arr[#arr] + processor(vim.fn.strcharpart(snippet_body, i, 1), state, node) + end + + -- Verify, post-process, normalize + H.parse_verify(state) + local nodes = H.parse_post_process(state.depth_arrays[1], state.name) + return opts.normalize and H.parse_normalize(nodes, opts) or nodes +end + +-- TODO: Implement this when adding snippet support in 'mini.completion' +-- MiniSnippets.mock_lsp_server = function() end + +-- Helper data ================================================================ +-- Module default config +H.default_config = vim.deepcopy(MiniSnippets.config) + +-- Namespaces for extmarks +H.ns_id = { + nodes = vim.api.nvim_create_namespace('MiniSnippetsNodes'), +} + +-- Array of current (nested) snippet sessions from `default_insert` +H.sessions = {} + +-- Various cache +H.cache = { + -- Loaders output + runtime = {}, + file = {}, + -- Data for possibly overridden session mappings + mappings = {}, +} + +-- Capabilties of current Neovim version +H.nvim_supports_inline_extmarks = vim.fn.has('nvim-0.10') == 1 + +-- Helper functionality ======================================================= +-- Settings ------------------------------------------------------------------- +H.setup_config = function(config) + -- General idea: if some table elements are not present in user-supplied + -- `config`, take them from default config + vim.validate({ config = { config, 'table', true } }) + config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {}) + + vim.validate({ + snippets = { config.snippets, 'table' }, + expand = { config.expand, 'table' }, + mappings = { config.mappings, 'table' }, + }) + + vim.validate({ + ['mappings.expand'] = { config.mappings.expand, 'string' }, + ['mappings.jump_next'] = { config.mappings.jump_next, 'string' }, + ['mappings.jump_prev'] = { config.mappings.jump_prev, 'string' }, + ['mappings.stop'] = { config.mappings.stop, 'string' }, + ['expand.prepare'] = { config.expand.prepare, 'function', true }, + ['expand.match'] = { config.expand.match, 'function', true }, + ['expand.select'] = { config.expand.select, 'function', true }, + ['expand.insert'] = { config.expand.insert, 'function', true }, + }) + + return config +end + +H.apply_config = function(config) + MiniSnippets.config = config + + -- Reset loader cache + H.cache = { runtime = {}, file = {}, mappings = {} } + + -- Make mappings + local mappings = config.mappings + local map = function(lhs, rhs, desc) + if lhs == '' then return end + vim.keymap.set('i', lhs, rhs, { desc = desc }) + end + map(mappings.expand, 'lua MiniSnippets.expand()', 'Expand snippet') + + -- Register 'code-snippets' extension as JSON (helps with highlighting) + vim.schedule(function() vim.filetype.add({ extension = { ['code-snippets'] = 'json' } }) end) +end + +H.create_autocommands = function() + local gr = vim.api.nvim_create_augroup('MiniSnippets', {}) + + local au = function(event, pattern, callback, desc) + vim.api.nvim_create_autocmd(event, { group = gr, pattern = pattern, callback = callback, desc = desc }) + end + + au('ColorScheme', '*', H.create_default_hl, 'Ensure colors') + + -- Clean up invalid sessions (i.e. which have outdated or corrupted data) + local clean_sessions = function() + for i = #H.sessions - 1, 1, -1 do + if not H.session_is_valid(H.sessions[i]) then + H.session_deinit(H.sessions[i], true) + table.remove(H.sessions, i) + end + end + if #H.sessions > 0 and not H.session_is_valid(H.get_active_session()) then MiniSnippets.session.stop() end + end + -- - Use `vim.schedule_wrap` to make it work with `:edit` command + au('BufUnload', '*', vim.schedule_wrap(clean_sessions), 'Clean sessions stack') +end + +H.create_default_hl = function() + local hi_link_underdouble = function(to, from) + local data = vim.fn.has('nvim-0.9') == 1 and vim.api.nvim_get_hl(0, { name = from, link = false }) + or vim.api.nvim_get_hl_by_name(from, true) + data.default = true + data.underdouble, data.underline, data.undercurl, data.underdotted, data.underdashed = + true, false, false, false, false + data.cterm = { underdouble = true } + data.fg, data.bg, data.ctermfg, data.ctermbg = 'NONE', 'NONE', 'NONE', 'NONE' + vim.api.nvim_set_hl(0, to, data) + end + hi_link_underdouble('MiniSnippetsCurrent', 'DiagnosticUnderlineWarn') + hi_link_underdouble('MiniSnippetsCurrentReplace', 'DiagnosticUnderlineError') + hi_link_underdouble('MiniSnippetsUnvisited', 'DiagnosticUnderlineHint') + hi_link_underdouble('MiniSnippetsVisited', 'DiagnosticUnderlineInfo') + hi_link_underdouble('MiniSnippetsFinal', 'DiagnosticUnderline' .. (vim.fn.has('nvim-0.9') == 1 and 'Ok' or 'Hint')) +end + +H.is_disabled = function() return vim.g.minisnippets_disable == true or vim.b.minisnippets_disable == true end + +H.get_config = function() + local global, buf = MiniSnippets.config, vim.b.minisnippets_config + -- Fast path for most common case + if buf == nil then return vim.deepcopy(global) end + -- Manually reconstruct to allow snippet array to be concatenated + buf = buf or {} + return { + snippets = vim.list_extend(vim.deepcopy(global.snippets), buf.snippets or {}), + mappings = vim.tbl_extend('force', global.mappings, buf.mappings or {}), + expand = vim.tbl_extend('force', global.expand, buf.expand or {}), + } +end + +-- Read ----------------------------------------------------------------------- +H.file_readers = {} + +H.file_readers.lua = function(path, silent) + local ok, contents = pcall(dofile, path) + if not ok then return { problems = { 'Could not execute Lua file' } } end + if type(contents) ~= 'table' then return { problems = { 'Returned object is not a table' } } end + return H.read_snippet_dict(contents) +end + +H.file_readers.json = function(path, silent) + local file = io.open(path) + if file == nil then return { problems = { 'Could not open file' } } end + local raw = file:read('*all') + file:close() + + local ok, contents = pcall(vim.json.decode, raw) + if not (ok and type(contents) == 'table') then + local msg = ok and 'Object is not a dictionary or array' or contents + return { problems = { 'File does not contain a valid JSON object. Reason: ' .. msg } } + end + + return H.read_snippet_dict(contents) +end + +H.file_readers['code-snippets'] = H.file_readers.json + +H.read_snippet_dict = function(contents) + local res, problems = {}, {} + for name, t in pairs(contents) do + if H.is_snippet(t) then + -- Try inferring description from dict's field (if appropriate) + if type(name) == 'string' and (t.desc == nil and t.description == nil) then t.desc = name end + table.insert(res, t) + else + table.insert(problems, 'The following is not a valid snippet data:\n' .. vim.inspect(t)) + end + end + return { snippets = res, problems = problems } +end + +-- Context snippets ----------------------------------------------------------- +H.get_default_context = function() + local buf_id = vim.api.nvim_get_current_buf() + local lang = vim.bo[buf_id].filetype + + -- TODO: Remove `opts.error` after compatibility with Neovim=0.11 is dropped + local has_parser, parser = pcall(vim.treesitter.get_parser, buf_id, nil, { error = false }) + if not has_parser or parser == nil then return { buf_id = buf_id, lang = lang } end + + -- Compute local TS language from the deepest parser covering position + local lnum, col = vim.fn.line('.'), vim.fn.col('.') + local ref_range, res_level = { lnum - 1, col - 1, lnum - 1, col }, 0 + local traverse + traverse = function(lang_tree, level) + if lang_tree:contains(ref_range) and level > res_level then lang = lang_tree:lang() or lang end + for _, child_lang_tree in pairs(lang_tree:children()) do + traverse(child_lang_tree, level + 1) + end + end + traverse(parser, 1) + + return { buf_id = buf_id, lang = lang } +end + +H.traverse_raw_snippets = function(x, target, context) + if H.is_snippet(x) then + local body + if x.body ~= nil then body = type(x.body) == 'string' and x.body or table.concat(x.body, '\n') end + + local desc = x.desc or x.description or body + if desc ~= nil then desc = type(desc) == 'string' and desc or table.concat(desc, '\n') end + + local prefix = x.prefix or '' + prefix = type(prefix) == 'string' and { prefix } or prefix + + for _, pr in ipairs(prefix) do + -- Add snippets with empty prefixes separately + local index = pr == '' and (#target + 1) or pr + -- Allow absent `body` to result in completely removing prefix(es) + target[index] = body ~= nil and { prefix = pr, body = body, desc = desc } or nil + end + end + + if H.islist(x) then + for _, v in ipairs(x) do + H.traverse_raw_snippets(v, target, context) + end + end + + if vim.is_callable(x) then H.traverse_raw_snippets(x(context), target, context) end +end + +-- Expand --------------------------------------------------------------------- +H.make_extended_insert = function(insert) + return function(snippet) + if snippet == nil then return end + + -- Ensure Insert mode. This helps to properly position cursor at EOL. + H.call_in_insert_mode(function() + -- Delete snippet's region and remove the data from the snippet (as it + -- wouldn't need to be removed and will represent outdated information) + H.delete_region(snippet.region) + snippet = vim.deepcopy(snippet) + snippet.region = nil + + -- Insert snippet at cursor + insert(snippet) + end) + end +end + +-- Parse ---------------------------------------------------------------------- +H.parse_verify = function(state) + if state.name == 'dollar_lbrace' then H.error('"${" should be closed with "}"') end + if state.name == 'choice' then H.error('Tabstop with choices should be closed with "|}"') end + if vim.startswith(state.name, 'transform_') then + H.error('Transform should contain 3 "/" outside of `${...}` and be closed with "}"') + end + if #state.depth_arrays > 1 then H.error('Placeholder should be closed with "}"') end +end + +H.parse_post_process = function(node_arr, state_name) + -- Allow "$" at the end of the snippet + if state_name == 'dollar' then table.insert(node_arr, { text = { '$' } }) end + + -- Process + local traverse + traverse = function(arr) + for _, node in ipairs(arr) do + -- Clean up trailing `\` + if node.after_slash and node.text ~= nil then table.insert(node.text, '\\') end + node.after_slash = nil + + -- Convert arrays to strings + if node.text then node.text = table.concat(node.text) end + if node.tabstop then node.tabstop = table.concat(node.tabstop) end + if node.choices then node.choices = vim.tbl_map(table.concat, node.choices) end + if node.var then node.var = table.concat(node.var) end + if node.transform then node.transform = vim.tbl_map(table.concat, node.transform) end + + -- Recursively post-process placeholders + if node.placeholder ~= nil then node.placeholder = traverse(node.placeholder) end + end + arr = vim.tbl_filter(function(n) return n.text == nil or (n.text ~= nil and n.text:len() > 0) end, arr) + if #arr == 0 then return { { text = '' } } end + return arr + end + + return traverse(node_arr) +end + +H.parse_normalize = function(node_arr, opts) + local lookup = {} + for key, val in pairs(opts.lookup) do + if type(key) == 'string' then lookup[key] = tostring(val) end + end + + local has_final_tabstop = false + local normalize = function(n) + -- Evaluate variable + local var_value + if n.var ~= nil then var_value = H.parse_eval_var(n.var, lookup) end + if type(var_value) == 'string' then n.text = var_value end + + -- Look up tabstop + if n.tabstop ~= nil then n.text = lookup[n.tabstop] end + + -- Ensure text-or-placeholder (use first choice for choice node) + if n.text == nil and n.placeholder == nil then n.placeholder = { { text = (n.choices or {})[1] or '' } } end + if n.text ~= nil and n.placeholder ~= nil then n.placeholder = nil end + + -- Track presence of final tabstop + has_final_tabstop = has_final_tabstop or n.tabstop == '0' + end + -- - Ensure proper random random variables + math.randomseed(vim.loop.hrtime()) + H.nodes_traverse(node_arr, normalize) + + -- Possibly append final tabstop as a regular normalized tabstop + if not has_final_tabstop then table.insert(node_arr, { tabstop = '0', placeholder = { { text = '' } } }) end + + -- Ensure same resolved text in linked tabstops + local tabstop_ref = {} + local sync_linked_tabstops = function(n) + if n.tabstop == nil then return end + local ref = tabstop_ref[n.tabstop] + if ref ~= nil then + -- Set data for repeated tabstops. Do not sync transforms (for future). + n.text, n.placeholder, n.choices = ref.text, vim.deepcopy(ref.placeholder), vim.deepcopy(ref.choices) + return + end + -- Compute reference data for repeated tabstops + if n.placeholder ~= nil and H.parse_nodes_contain_tabstop(n.placeholder, n.tabstop) then + H.error('Placeholder can not contain its tabstop') + end + tabstop_ref[n.tabstop] = { text = n.text, placeholder = n.placeholder, choices = n.choices } + end + H.nodes_traverse(node_arr, sync_linked_tabstops) + + return node_arr +end + +H.parse_nodes_contain_tabstop = function(node_arr, tabstop) + for _, n in ipairs(node_arr) do + if n.tabstop == tabstop then return true end + if n.placeholder ~= nil and H.parse_nodes_contain_tabstop(n.placeholder, tabstop) then return true end + end + return false +end + +H.parse_get_text = function(node_arr) + local parts = {} + for _, n in ipairs(node_arr) do + table.insert(parts, n.text or H.parse_get_text(n.placeholder)) + end + return table.concat(parts, '') +end + +H.parse_rise_depth = function(state) + -- Set the deepest array as a placeholder of the last node in previous layer. + -- This can happen only after `}` which does not close current node. + local depth = #state.depth_arrays + local cur_layer, prev_layer = state.depth_arrays[depth], state.depth_arrays[depth - 1] + prev_layer[#prev_layer].placeholder = vim.deepcopy(cur_layer) + state.depth_arrays[depth] = nil + state:add_node({ text = {} }):set_name('text') +end + +-- Each method processes single character based on the character (`c`), +-- state (`s`), and current node (`n`). +H.parse_processors = {} + +H.parse_processors.text = function(c, s, n) + if n.after_slash then + -- Escape `$}\` and allow unescaped '\\' to preceed any character + if not (c == '$' or c == '}' or c == '\\') then table.insert(n.text, '\\') end + n.text[#n.text + 1], n.after_slash = c, nil + return + end + if c == '}' and s:is_not_top_level() then return H.parse_rise_depth(s) end + if c == '\\' then return s:set_in(n, 'after_slash', true) end + if c == '$' then return s:set_name('dollar') end + table.insert(n.text, c) +end + +H.parse_processors.dollar = function(c, s, n) + if c == '}' and s:is_not_top_level() then + if n.text ~= nil then table.insert(n.text, '$') end + if n.text == nil then s:add_node({ text = { '$' } }) end + s:set_name('text') + H.parse_rise_depth(s) + return + end + + if c:find('^[0-9]$') then return s:add_node({ tabstop = { c } }):set_name('dollar_tabstop') end -- Tabstops + if c:find('^[_a-zA-Z]$') then return s:add_node({ var = { c } }):set_name('dollar_var') end -- Variables + if c == '{' then return s:set_name('dollar_lbrace') end -- Cases of `${...}` + table.insert(n.text, '$') -- Case of unescaped `$` + if c == '$' then return end -- Case of `$$1` and `$${1}` + table.insert(n.text, c) + s:set_name('text') +end + +H.parse_processors.dollar_tabstop = function(c, s, n) + if c:find('^[0-9]$') then return table.insert(n.tabstop, c) end + if c == '}' and s:is_not_top_level() then return H.parse_rise_depth(s) end + local new_node = { text = {} } + s:add_node(new_node) + if c == '$' then return s:set_name('dollar') end -- Case of `$1$2` and `$1$a` + table.insert(new_node.text, c) -- Case of `$1a` + s:set_name('text') +end + +H.parse_processors.dollar_var = function(c, s, n) + if c:find('^[_a-zA-Z0-9]$') then return table.insert(n.var, c) end + if c == '}' and s:is_not_top_level() then return H.parse_rise_depth(s) end + local new_node = { text = {} } + s:add_node(new_node) + if c == '$' then return s:set_name('dollar') end -- Case of `$a$b` and `$a$1` + table.insert(new_node.text, c) -- Case of `$a-` + s:set_name('text') +end + +H.parse_processors.dollar_lbrace = function(c, s, n) + if n.tabstop == nil and n.var == nil then -- Detect the type of `${...}` + if c:find('^[0-9]$') then return s:add_node({ tabstop = { c } }) end + if c:find('^[_a-zA-Z]$') then return s:add_node({ var = { c } }) end + H.error('`${` should be followed by digit (in tabstop) or letter/underscore (in variable), not ' .. vim.inspect(c)) + end + if c == '}' then return s:add_node({ text = {} }):set_name('text') end -- Cases of `${1}` and `${a}` + if c == ':' then -- Placeholder + table.insert(s.depth_arrays, { { text = {} } }) + return s:set_name('text') + end + if c == '/' then return s:set_in(n, 'transform', { {}, {}, {} }):set_name('transform_regex') end -- Transform + if n.var ~= nil then -- Variable + if c:find('^[_a-zA-Z0-9]$') then return table.insert(n.var, c) end + H.error('Variable name should be followed by "}", ":" or "/", not ' .. vim.inspect(c)) + else -- Tabstop + if c:find('^[0-9]$') then return table.insert(n.tabstop, c) end + if c == '|' then return s:set_name('choice') end + H.error('Tabstop id should be followed by "}", ":", "|", or "/" not ' .. vim.inspect(c)) + end +end + +H.parse_processors.choice = function(c, s, n) + n.choices = n.choices or { {} } + if n.after_bar then + if c ~= '}' then H.error('Tabstop with choices should be closed with "|}"') end + return s:set_in(n, 'after_bar', nil):add_node({ text = {} }):set_name('text') + end + + local cur = n.choices[#n.choices] + if n.after_slash then + -- Escape `$}\` and allow unescaped '\\' to preceed any character + if not (c == ',' or c == '|' or c == '\\') then table.insert(cur, '\\') end + cur[#cur + 1], n.after_slash = c, nil + return + end + if c == ',' then return table.insert(n.choices, {}) end + if c == '\\' then return s:set_in(n, 'after_slash', true) end + if c == '|' then return s:set_in(n, 'after_bar', true) end + table.insert(cur, c) +end + +-- Silently gather all the transform data and wait until proper `}` +H.parse_processors.transform_regex = function(c, s, n) + table.insert(n.transform[1], c) + if n.after_slash then return s:set_in(n, 'after_slash', nil) end + if c == '\\' then return s:set_in(n, 'after_slash', true) end + if c == '/' then return s:set_in(n.transform[1], #n.transform[1], nil):set_name('transform_format') end -- Assumes any `/` is escaped in regex +end + +H.parse_processors.transform_format = function(c, s, n) + table.insert(n.transform[2], c) + if n.after_slash then return s:set_in(n, 'after_slash', nil) end + if n.after_dollar then + n.after_dollar = nil + -- Inside `${}` wait until the first (unescaped) `}`. Techincally, this + -- breaks LSP spec in `${1:?if:else}` (`if` doesn't have to escape `}`). + -- Accept this as known limitation and ask to escape `}` in such cases. + if c == '{' and not n.inside_braces then return s:set_in(n, 'inside_braces', true) end + end + if c == '\\' then return s:set_in(n, 'after_slash', true) end + if c == '$' then return s:set_in(n, 'after_dollar', true) end + if c == '}' and n.inside_braces then return s:set_in(n, 'inside_braces', nil) end + if c == '/' and not n.inside_braces then + return s:set_in(n.transform[2], #n.transform[2], nil):set_name('transform_options') + end +end + +H.parse_processors.transform_options = function(c, s, n) + table.insert(n.transform[3], c) + if n.after_slash then return s:set_in(n, 'after_slash', nil) end + if c == '\\' then return s:set_in(n, 'after_slash', true) end + if c == '}' then return s:set_in(n.transform[3], #n.transform[3], nil):add_node({ text = {} }):set_name('text') end +end + +--stylua: ignore +H.parse_eval_var = function(var, lookup) + -- Always prefer using lookup + if lookup[var] ~= nil then return lookup[var] end + + -- Evaluate variable + local value + if H.var_evaluators[var] ~= nil then value = H.var_evaluators[var]() end + -- - Fall back to environment variable or `-1` to not evaluate twice + if value == nil then value = vim.loop.os_getenv(var) or -1 end + + -- Skip caching random variables (to allow several different in one snippet) + if not (var == 'RANDOM' or var == 'RANDOM_HEX' or var == 'UUID') then lookup[var] = value end + return value +end + +--stylua: ignore +H.var_evaluators = { + -- LSP + TM_SELECTED_TEXT = function() return vim.fn.getreg('"') end, + TM_CURRENT_LINE = function() return vim.api.nvim_get_current_line() end, + TM_CURRENT_WORD = function() return vim.fn.expand('') end, + TM_LINE_INDEX = function() return tostring(vim.fn.line('.') - 1) end, + TM_LINE_NUMBER = function() return tostring(vim.fn.line('.')) end, + TM_FILENAME = function() return vim.fn.expand('%:t') end, + TM_FILENAME_BASE = function() return vim.fn.expand('%:t:r') end, + TM_DIRECTORY = function() return vim.fn.expand('%:p:h') end, + TM_FILEPATH = function() return vim.fn.expand('%:p') end, + + -- VS Code + CLIPBOARD = function() return vim.fn.getreg('+') end, + CURSOR_INDEX = function() return tostring(vim.fn.col('.') - 1) end, + CURSOR_NUMBER = function() return tostring(vim.fn.col('.')) end, + RELATIVE_FILEPATH = function() return vim.fn.expand('%:.') end, + WORKSPACE_FOLDER = function() return vim.fn.getcwd() end, + + LINE_COMMENT = function() return vim.bo.commentstring:gsub('%s*%%s.*$', '') end, + -- No BLOCK_COMMENT_{START,END} as there is no built-in way to get them + + CURRENT_YEAR = function() return vim.fn.strftime('%Y') end, + CURRENT_YEAR_SHORT = function() return vim.fn.strftime('%y') end, + CURRENT_MONTH = function() return vim.fn.strftime('%m') end, + CURRENT_MONTH_NAME = function() return vim.fn.strftime('%B') end, + CURRENT_MONTH_NAME_SHORT = function() return vim.fn.strftime('%b') end, + CURRENT_DATE = function() return vim.fn.strftime('%d') end, + CURRENT_DAY_NAME = function() return vim.fn.strftime('%A') end, + CURRENT_DAY_NAME_SHORT = function() return vim.fn.strftime('%a') end, + CURRENT_HOUR = function() return vim.fn.strftime('%H') end, + CURRENT_MINUTE = function() return vim.fn.strftime('%M') end, + CURRENT_SECOND = function() return vim.fn.strftime('%S') end, + CURRENT_TIMEZONE_OFFSET = function() return vim.fn.strftime('%z') end, + + CURRENT_SECONDS_UNIX = function() return tostring(os.time()) end, + + -- Random + RANDOM = function() return string.format('%06d', math.random(0, 999999)) end, + RANDOM_HEX = function() return string.format('%06x', math.random(0, 16777216 - 1)) end, + UUID = function() + -- Source: https://gist.github.com/jrus/3197011 + local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + return string.gsub(template, '[xy]', function (c) + local v = c == 'x' and math.random(0, 0xf) or math.random(8, 0xb) + return string.format('%x', v) + end) + end +} + +-- Session -------------------------------------------------------------------- +H.get_active_session = function() return H.sessions[#H.sessions] end + +H.session_new = function(nodes, snippet, opts) + -- Compute all present tabstops in session traverse order + local taborder = H.compute_tabstop_order(nodes) + local tabstops = {} + for i, id in ipairs(taborder) do + tabstops[id] = + { prev = taborder[i - 1] or taborder[#taborder], next = taborder[i + 1] or taborder[1], is_visited = false } + end + + return { + buf_id = vim.api.nvim_get_current_buf(), + cur_tabstop = taborder[1], + extmark_id = H.extmark_new(0, vim.fn.line('.') - 1, vim.fn.col('.') - 1), + insert_args = vim.deepcopy({ snippet = snippet, opts = opts }), + nodes = nodes, + ns_id = H.ns_id.nodes, + tabstops = tabstops, + } +end + +H.session_init = function(session, full) + if session == nil then return end + local buf_id = session.buf_id + + -- Prepare + if full then + -- Set buffer text + H.nodes_set_text(buf_id, session.nodes, session.extmark_id, H.get_indent()) + + -- No session if no input needed: single final tabstop without placeholder + if session.cur_tabstop == '0' then + local ref_node = H.session_get_ref_node(session) + local row, col, opts = H.extmark_get(buf_id, ref_node.extmark_id) + local is_empty = row == opts.end_row and col == opts.end_col + if is_empty then + -- Clean up + H.nodes_traverse(session.nodes, function(n) H.extmark_del(buf_id, n.extmark_id) end) + H.extmark_del(buf_id, session.extmark_id) + return H.set_cursor({ row + 1, col }) + end + end + + -- Register new session + local cur_session = H.get_active_session() + if cur_session ~= nil then + -- Sync before deinit to allow removing current placeholder + H.session_sync_current_tabstop(cur_session) + H.session_deinit(cur_session, false) + end + table.insert(H.sessions, session) + + -- Focus on the current tabstop + H.session_tabstop_focus(session, session.cur_tabstop) + + -- Possibly set behavior for all sessions + H.track_sessions() + H.map_in_sessions() + else + -- Sync current tabstop for resumed session. This is useful when nested + -- session was done inside reference tabstop node (most common case). + -- On purpose don't change cursor/buffer/focus to allow smoother typing. + H.session_sync_current_tabstop(session) + H.session_update_hl(session) + H.session_ensure_gravity(session) + end + + -- Trigger proper event + H.trigger_event('MiniSnippetsSession' .. (full and 'Start' or 'Resume'), { session = vim.deepcopy(session) }) +end + +H.track_sessions = function() + -- Create tracking autocommands only once for all nested sessions + if #H.sessions > 1 then return end + local gr = vim.api.nvim_create_augroup('MiniSnippetsTrack', { clear = true }) + + -- React to text changes. NOTE: Use 'TextChangedP' to update linked tabstops + -- with visible popup. It has downsides though: + -- - Placeholder is removed after selecting first choice. Together with + -- showing choices in empty tabstops, feels like a good compromise. + -- - Tabstop sync runs more frequently (especially with 'mini.completion'), + -- because of how built-in completion constantly 'delete-add' completion + -- leader text (which is treated as text change). + local on_textchanged = function(args) + local session, buf_id = H.get_active_session(), args.buf + -- React only to text changes in session's buffer for performance + if session.buf_id ~= buf_id then return end + -- Ensure that session is valid, like no extmarks got corrupted + if not H.session_is_valid(session) then + H.notify('Session contains corrupted data (deleted or out of range extmarks). It is stopped.', 'WARN') + return MiniSnippets.session.stop() + end + H.session_sync_current_tabstop(session) + end + local text_events = { 'TextChanged', 'TextChangedI', 'TextChangedP' } + vim.api.nvim_create_autocmd(text_events, { group = gr, callback = on_textchanged, desc = 'React to text change' }) + + -- Stop if final tabstop is current: exit to Normal mode or *any* text change + local latest_changedtick = vim.b.changedtick + local stop_if_final = function(args) + -- *Actual* text change check is a workaround for `TextChangedI` sometimes + -- getting triggered unnecessarily and too late with built-in completion + if vim.b.changedtick == latest_changedtick and args.event ~= 'ModeChanged' then return end + latest_changedtick = vim.b.changedtick + + -- React only on text changes in session's buffer + local session, buf_id = H.get_active_session(), args.buf + if not ((session or {}).buf_id == buf_id and session.cur_tabstop == '0') then return end + + -- Stop without forcing to hide completion + H.cache.stop_is_auto = true + MiniSnippets.session.stop() + H.cache.stop_is_auto = nil + end + local modechanged_opts = { group = gr, pattern = '*:n', callback = stop_if_final, desc = 'Stop on final tabstop' } + vim.api.nvim_create_autocmd('ModeChanged', modechanged_opts) + vim.api.nvim_create_autocmd(text_events, { group = gr, callback = stop_if_final, desc = 'Stop on final tabstop' }) +end + +H.map_in_sessions = function() + -- Create mapping only once for all nested sessions + if #H.sessions > 1 then return end + local mappings = H.get_config().mappings + local map_with_cache = function(lhs, call, desc) + if lhs == '' then return end + H.cache.mappings[lhs] = vim.fn.maparg(lhs, 'i', false, true) + -- NOTE: Map globally to work in nested sessions in different buffers + vim.keymap.set('i', lhs, 'lua MiniSnippets.session.' .. call .. '', { desc = desc }) + end + map_with_cache(mappings.jump_next, 'jump("next")', 'Jump to next snippet tabstop') + map_with_cache(mappings.jump_prev, 'jump("prev")', 'Jump to previous snippet tabstop') + map_with_cache(mappings.stop, 'stop()', 'Stop active snippet session') +end + +H.unmap_in_sessions = function() + for lhs, data in pairs(H.cache.mappings) do + local needs_restore = vim.tbl_count(data) > 0 + if needs_restore then vim.fn.mapset('i', false, data) end + if not needs_restore then vim.keymap.del('i', lhs) end + end + H.cache.mappings = {} +end + +H.session_tabstop_focus = function(session, tabstop_id) + session.cur_tabstop = tabstop_id + session.tabstops[tabstop_id].is_visited = true + + -- Ensure target buffer is current + H.ensure_cur_buf(session.buf_id) + + -- Update highlighting + H.session_update_hl(session) + + -- Ensure proper gravity as reference node has changed + H.session_ensure_gravity(session) + + -- Set cursor based on reference node: left side if there is placeholder (and + -- will be replaced), right side otherwise (to append). + local ref_node = H.session_get_ref_node(session) + local row, col, end_row, end_col = H.extmark_get_range(session.buf_id, ref_node.extmark_id) + local pos = ref_node.placeholder ~= nil and { row + 1, col } or { end_row + 1, end_col } + H.set_cursor(pos) + + -- Show choices: if present and match node text (or all if still placeholder) + local matched_choices = H.session_match_choices(ref_node.choices, ref_node.text or '') + H.show_completion(matched_choices, col + 1) +end + +H.session_ensure_gravity = function(session) + -- Ensure proper gravity relative to reference node (first node with current + -- tabstop): "left" before, "expand" at, "right" after. This should account + -- for typing in snippets like `$1$2$1$2$1` (in both 1 and 2). + local buf_id, cur_tabstop, base_gravity = session.buf_id, session.cur_tabstop, 'left' + local ensure = function(n) + local is_ref_node = n.tabstop == cur_tabstop and base_gravity == 'left' + H.extmark_set_gravity(buf_id, n.extmark_id, is_ref_node and 'expand' or base_gravity) + base_gravity = (is_ref_node or base_gravity == 'right') and 'right' or 'left' + end + -- NOTE: This relies on `H.nodes_traverse` to first apply to the node and + -- only later (recursively) to placeholder nodes, which makes them all have + -- "right" gravity and thus being removable during replacing placeholder (as + -- they will not cover newly inserted text). + H.nodes_traverse(session.nodes, ensure) +end + +H.session_get_ref_node = function(session) + local res, cur_tabstop = nil, session.cur_tabstop + local find = function(n) res = res or (n.tabstop == cur_tabstop and n or nil) end + H.nodes_traverse(session.nodes, find) + return res +end + +H.session_match_choices = function(choices, prefix) + if choices == nil then return {} end + if prefix == '' then return choices end + if vim.o.completeopt:find('fuzzy') ~= nil then return vim.fn.matchfuzzy(choices, prefix) end + return vim.tbl_filter(function(c) return vim.startswith(c, prefix) end, choices) +end + +H.session_is_valid = function(session) + local buf_id = session.buf_id + if not H.is_loaded_buf(buf_id) then return false end + local res, f, n_lines = true, nil, vim.api.nvim_buf_line_count(buf_id) + f = function(n) + -- NOTE: Invalid extmark tracking (via `invalidate=true`) should be doable, + -- but comes with constraints: manually making tabstop empty should be + -- allowed; deleting placeholder also deletes extmark's range. Both make + -- extmark invalid, so deligate to users to see that extmarks are broken. + local ok, row, _, _ = pcall(H.extmark_get, buf_id, n.extmark_id) + res = res and (ok and row < n_lines) + end + H.nodes_traverse(session.nodes, f) + return res +end + +H.session_sync_current_tabstop = function(session) + if session._no_sync then return end + + local buf_id, ref_node = session.buf_id, H.session_get_ref_node(session) + local ref_extmark_id = ref_node.extmark_id + + -- With present placeholder, decide whether there was a valid change (then + -- remove placeholder) or not (then no sync) + -- NOTE: Only current tabstop is synced *and* only if after its first edit is + -- mostly done to limit code complexity. This is a reasonable compromise + -- together with `parse()` syncing all tabstops in its normalization. Doing + -- more is better for cases which are outside of suggested workflow (like + -- editing text outside of "jump-edit-jump-edit-stop" loop). + if ref_node.placeholder ~= nil then + local ref_row, ref_col = H.extmark_get_range(buf_id, ref_extmark_id) + local phd_row, phd_col = H.extmark_get_range(buf_id, ref_node.placeholder[1].extmark_id) + if ref_row == phd_row and ref_col == phd_col then return end + + -- Remove placeholder to get extmark representing newly added text + H.nodes_del(buf_id, ref_node.placeholder) + ref_node.placeholder = nil + end + + -- Compute target text + local row, col, end_row, end_col = H.extmark_get_range(buf_id, ref_extmark_id) + local cur_text = vim.api.nvim_buf_get_text(0, row, col, end_row, end_col, {}) + cur_text = table.concat(cur_text, '\n') + + -- Sync nodes with current tabstop to have text from reference node + local cur_tabstop = session.cur_tabstop + local sync = function(n) + if n.tabstop == cur_tabstop then + if n.placeholder ~= nil then H.nodes_del(buf_id, n.placeholder) end + H.extmark_set_gravity(buf_id, n.extmark_id, 'expand') + if n.extmark_id ~= ref_extmark_id then H.extmark_set_text(buf_id, n.extmark_id, 'inside', cur_text) end + n.placeholder, n.text = nil, cur_text + end + -- Make sure node's extmark doesn't move when setting later text + H.extmark_set_gravity(buf_id, n.extmark_id, 'left') + end + -- - Temporarily disable running this function (as autocommands will trigger) + session._no_sync = true + H.nodes_traverse(session.nodes, sync) + session._no_sync = nil + H.session_ensure_gravity(session) + + -- Maybe show choices + if cur_text == '' then H.show_completion(ref_node.choices) end + + -- Make highlighting up to date + H.session_update_hl(session) +end + +H.session_jump = vim.schedule_wrap(function(session, direction) + -- NOTE: Use `schedule_wrap` to workaround some edge cases when used inside + -- expression mapping (as recommended for ``) + if session == nil then return end + + -- Compute target tabstop accounting for possibly missing ones. + -- Example why needed: `${1:$2}$3`, setting text in $1 removes $2 tabstop + -- and jumping should be done from 1 to 3. + local present_tabstops, all_tabstops = {}, session.tabstops + H.nodes_traverse(session.nodes, function(n) present_tabstops[n.tabstop or true] = true end) + local cur_tabstop, new_tabstop = session.cur_tabstop, nil + -- - NOTE: This can't be infinite as `prev`/`next` traverse all tabstops + if not present_tabstops[cur_tabstop] then return end + while not present_tabstops[new_tabstop] do + new_tabstop = all_tabstops[new_tabstop or cur_tabstop][direction] + end + + local event_data = { tabstop_from = cur_tabstop, tabstop_to = new_tabstop } + H.trigger_event('MiniSnippetsSessionJumpPre', event_data) + H.session_tabstop_focus(session, new_tabstop) + H.trigger_event('MiniSnippetsSessionJump', event_data) +end) + +H.session_update_hl = function(session) + local buf_id, insert_opts = session.buf_id, session.insert_args.opts + local empty_tabstop, empty_tabstop_final = insert_opts.empty_tabstop, insert_opts.empty_tabstop_final + local cur_tabstop, tabstops = session.cur_tabstop, session.tabstops + local is_replace = H.session_get_ref_node(session).placeholder ~= nil + local current_hl = 'MiniSnippetsCurrent' .. (is_replace and 'Replace' or '') + local priority = 101 + + local update_hl = function(n, is_in_cur_tabstop) + if n.tabstop == nil then return end + + -- Compute tabstop's features + local row, col, opts = H.extmark_get(buf_id, n.extmark_id) + local is_empty = row == opts.end_row and col == opts.end_col + local is_final = n.tabstop == '0' + local is_visited = tabstops[n.tabstop].is_visited + local hl_group = (n.tabstop == cur_tabstop or is_in_cur_tabstop) and current_hl + or (is_final and 'MiniSnippetsFinal' or (is_visited and 'MiniSnippetsVisited' or 'MiniSnippetsUnvisited')) + + -- Ensure up to date highlighting + opts.hl_group, opts.virt_text_pos, opts.virt_text = nil, nil, nil + + if is_empty then + if H.nvim_supports_inline_extmarks then + opts.virt_text_pos = 'inline' + opts.virt_text = { { is_final and empty_tabstop_final or empty_tabstop, hl_group } } + end + else + opts.hl_group = hl_group + end + + -- Make inline extmarks preserve order if placed at same position + priority = priority + 1 + opts.priority = priority + + -- Update extmark + vim.api.nvim_buf_set_extmark(buf_id, H.ns_id.nodes, row, col, opts) + end + + -- Use custom traversing to ensure that nested tabstops inside current + -- tabstop's placeholder are highlighted the same, even inline virtual text. + local update_hl_in_nodes + update_hl_in_nodes = function(nodes, is_in_cur_tabstop) + for _, n in ipairs(nodes) do + update_hl(n, is_in_cur_tabstop) + if n.placeholder ~= nil then update_hl_in_nodes(n.placeholder, is_in_cur_tabstop or n.tabstop == cur_tabstop) end + end + end + update_hl_in_nodes(session.nodes, false) +end + +H.session_deinit = function(session, full) + if session == nil then return end + + -- Trigger proper event + H.trigger_event('MiniSnippetsSession' .. (full and 'Stop' or 'Suspend'), { session = vim.deepcopy(session) }) + if not H.is_loaded_buf(session.buf_id) then return end + + -- Delete or hide (make invisible) extmarks + local extmark_fun = full and H.extmark_del or H.extmark_hide + extmark_fun(session.buf_id, session.extmark_id) + H.nodes_traverse(session.nodes, function(n) extmark_fun(session.buf_id, n.extmark_id) end) + + -- Hide completion if stopping was done manually + if not H.cache.stop_is_auto then H.hide_completion() end +end + +H.nodes_set_text = function(buf_id, nodes, tracking_extmark_id, indent) + for _, n in ipairs(nodes) do + -- Add tracking extmark + local _, _, row, col = H.extmark_get_range(buf_id, tracking_extmark_id) + n.extmark_id = H.extmark_new(buf_id, row, col) + + -- Adjust node's text and append it to currently set text + if n.text ~= nil then + local new_text = n.text:gsub('\n', '\n' .. indent) + if vim.bo.expandtab then + local sw = vim.bo.shiftwidth + new_text = new_text:gsub('\t', string.rep(' ', sw == 0 and vim.bo.tabstop or sw)) + end + H.extmark_set_text(buf_id, tracking_extmark_id, 'right', new_text) + end + + -- Process (possibly nested) placeholder nodes + if n.placeholder ~= nil then H.nodes_set_text(buf_id, n.placeholder, tracking_extmark_id, indent) end + + -- Make sure that node's extmark doesn't move when adding next node text + H.extmark_set_gravity(buf_id, n.extmark_id, 'left') + end +end + +H.nodes_del = function(buf_id, nodes) + local del = function(n) + H.extmark_set_text(buf_id, n.extmark_id, 'inside', {}) + H.extmark_del(buf_id, n.extmark_id) + end + H.nodes_traverse(nodes, del) +end + +H.nodes_traverse = function(nodes, f) + for i, n in ipairs(nodes) do + -- Visit whole node first to allow `f` to modify placeholder. This is also + -- important to ensure proper gravity inside placeholder nodes. + local out = f(n) + if out ~= nil then n = out end + if n.placeholder ~= nil then n.placeholder = H.nodes_traverse(n.placeholder, f) end + nodes[i] = n + end + return nodes +end + +H.compute_tabstop_order = function(nodes) + local tabstops_map = {} + H.nodes_traverse(nodes, function(n) tabstops_map[n.tabstop or true] = true end) + tabstops_map[true] = nil + + -- Order as numbers while allowing leading zeros. Put special `$0` last. + local tabstops = vim.tbl_map(function(x) return { tonumber(x), x } end, vim.tbl_keys(tabstops_map)) + table.sort(tabstops, function(a, b) + if a[2] == '0' then return false end + if b[2] == '0' then return true end + return a[1] < b[1] or (a[1] == b[1] and a[2] < b[2]) + end) + return vim.tbl_map(function(x) return x[2] end, tabstops) +end + +-- Extmarks ------------------------------------------------------------------- +-- All extmark functions work in current buffer with same global namespace. +-- This is because interaction with snippets eventually requires buffer to be +-- current, so instead rely on it becoming such as soon as possible. +H.extmark_new = function(buf_id, row, col) + -- Create expanding extmark by default + local opts = { end_row = row, end_col = col, right_gravity = false, end_right_gravity = true } + return vim.api.nvim_buf_set_extmark(buf_id, H.ns_id.nodes, row, col, opts) +end + +H.extmark_get = function(buf_id, ext_id) + local data = vim.api.nvim_buf_get_extmark_by_id(buf_id, H.ns_id.nodes, ext_id, { details = true }) + data[3].id, data[3].ns_id = ext_id, nil + return data[1], data[2], data[3] +end + +H.extmark_get_range = function(buf_id, ext_id) + local row, col, opts = H.extmark_get(buf_id, ext_id) + return row, col, opts.end_row, opts.end_col +end + +H.extmark_del = function(buf_id, ext_id) vim.api.nvim_buf_del_extmark(buf_id, H.ns_id.nodes, ext_id or -1) end + +H.extmark_hide = function(buf_id, ext_id) + local row, col, opts = H.extmark_get(buf_id, ext_id) + opts.hl_group, opts.virt_text, opts.virt_text_pos = nil, nil, nil + vim.api.nvim_buf_set_extmark(buf_id, H.ns_id.nodes, row, col, opts) +end + +H.extmark_set_gravity = function(buf_id, ext_id, gravity) + local row, col, opts = H.extmark_get(buf_id, ext_id) + opts.right_gravity, opts.end_right_gravity = gravity == 'right', gravity ~= 'left' + vim.api.nvim_buf_set_extmark(buf_id, H.ns_id.nodes, row, col, opts) +end + +--stylua: ignore +H.extmark_set_text = function(buf_id, ext_id, side, text) + local row, col, end_row, end_col = H.extmark_get_range(buf_id, ext_id) + if side == 'left' then end_row, end_col = row, col end + if side == 'right' then row, col = end_row, end_col end + text = type(text) == 'string' and vim.split(text, '\n') or text + vim.api.nvim_buf_set_text(buf_id, row, col, end_row, end_col, text) +end + +-- Indent --------------------------------------------------------------------- +H.get_indent = function(lnum) + local line, comment_indent = vim.fn.getline(lnum or '.'), '' + -- Compute "indent at cursor" + local trunc_col = (lnum == nil or lnum == '.') and (vim.fn.col('.') - 1) or line:len() + line = line:sub(1, trunc_col) + -- Treat comment leaders as part of indent + for _, leader in ipairs(H.get_comment_leaders()) do + local cur_match = line:match('^%s*' .. vim.pesc(leader) .. '%s*') + -- Use biggest match in case of several matches. Allows respecting "nested" + -- comment leaders like "---" and "--". + if type(cur_match) == 'string' and comment_indent:len() < cur_match:len() then comment_indent = cur_match end + end + return comment_indent ~= '' and comment_indent or line:match('^%s*') +end + +H.get_comment_leaders = function() + local res = {} + + -- From 'commentstring' + local main_leader = vim.split(vim.bo.commentstring, '%%s')[1] + table.insert(res, vim.trim(main_leader)) + + -- From 'comments' + for _, comment_part in ipairs(vim.opt_local.comments:get()) do + local prefix, suffix = comment_part:match('^(.*):(.*)$') + suffix = vim.trim(suffix) + if prefix:find('b') then + -- Respect `b` flag (for blank) requiring space, tab or EOL after it + table.insert(res, suffix .. ' ') + table.insert(res, suffix .. '\t') + elseif prefix:find('f') == nil then + -- Add otherwise ignoring `f` flag (only first line should have it) + table.insert(res, suffix) + end + end + + return res +end + +-- Validators ----------------------------------------------------------------- +H.is_string = function(x) return type(x) == 'string' end + +H.is_maybe_string_or_arr = function(x) return x == nil or H.is_string(x) or H.is_array_of(x, H.is_string) end + +H.is_snippet = function(x) + return type(x) == 'table' + -- Allow nil `prefix`: inferred as empty string + and H.is_maybe_string_or_arr(x.prefix) + -- Allow nil `body` to remove snippet with `prefix` + and H.is_maybe_string_or_arr(x.body) + -- Allow nil `desc` / `description`, in which case "prefix" is used + and H.is_maybe_string_or_arr(x.desc) + and H.is_maybe_string_or_arr(x.description) + -- Allow nil `region` because it is not mandatory + and (x.region == nil or H.is_region(x.region)) +end + +H.is_position = function(x) return type(x) == 'table' and type(x.line) == 'number' and type(x.col) == 'number' end + +H.is_region = function(x) return type(x) == 'table' and H.is_position(x.from) and H.is_position(x.to) end + +-- Utilities ------------------------------------------------------------------ +H.error = function(msg) error('(mini.snippets) ' .. msg, 0) end + +H.notify = function(msg, level_name, silent) + if not silent then vim.notify('(mini.snippets) ' .. msg, vim.log.levels[level_name]) end +end + +H.trigger_event = function(event_name, data) vim.api.nvim_exec_autocmds('User', { pattern = event_name, data = data }) end + +H.is_array_of = function(x, predicate) + if not H.islist(x) then return false end + for i = 1, #x do + if not predicate(x[i]) then return false end + end + return true +end + +H.is_loaded_buf = function(buf_id) return type(buf_id) == 'number' and vim.api.nvim_buf_is_loaded(buf_id) end + +H.ensure_cur_buf = function(buf_id) + if buf_id == 0 or buf_id == vim.api.nvim_get_current_buf() or not H.is_loaded_buf(buf_id) then return end + local win_id = vim.fn.win_findbuf(buf_id)[1] + if win_id == nil then return vim.api.nvim_win_set_buf(0, buf_id) end + vim.api.nvim_set_current_win(win_id) +end + +H.set_cursor = function(pos) + -- Ensure no built-in completion window + -- HACK: Always clearing (and not *only* when pumvisible) accounts for weird + -- edge case when it is not visible (i.e. candidates *just* got exhausted) + -- but will still "clear and restore" text leading to squashing of extmarks. + H.hide_completion() + + -- NOTE: This won't put cursor past enf of line (for cursor in Insert mode to + -- append text to the line). Ensure that Insert mode is active prior. + vim.api.nvim_win_set_cursor(0, pos) +end + +H.call_in_insert_mode = function(f) + if vim.fn.mode() == 'i' then return f() end + + -- This is seemingly the only "good" way to ensure Insert mode. + -- Mostly because it works with `vim.snippet.expand()` as its implementation + -- uses `vim.api.nvim_feedkeys(k, 'n', true)` to select text in Select mode. + vim.api.nvim_feedkeys('\28\14i', 'n', false) + + -- NOTE: mode changing is not immediate, only on some next tick. So schedule + -- to execute `f` precisely when Insert mode is active. + local cb = function() f() end + vim.api.nvim_create_autocmd('ModeChanged', { pattern = '*:i*', once = true, callback = cb, desc = 'Call in Insert' }) +end + +H.delete_region = function(region) + if not H.is_region(region) then return end + vim.api.nvim_buf_set_text(0, region.from.line - 1, region.from.col - 1, region.to.line - 1, region.to.col, {}) + H.set_cursor({ region.from.line, region.from.col - 1 }) +end + +H.show_completion = function(items, startcol) + if items == nil or #items == 0 or vim.fn.mode() ~= 'i' then return end + vim.fn.complete(startcol or vim.fn.col('.'), items) +end + +H.hide_completion = function() + -- NOTE: `complete()` instead of emulating has immediate effect + -- (without the need to `vim.schedule()`). The downside is that `fn.mode(1)` + -- returns 'ic' (i.e. not "i" for clean Insert mode). Appending + -- ` | call feedkeys("\\", "n")` removes that, but still would require + -- workarounds to work in edge cases. + if vim.fn.mode() == 'i' then vim.cmd('noautocmd call complete(col("."), [])') end +end + +-- TODO: Remove after compatibility with Neovim=0.9 is dropped +H.islist = vim.fn.has('nvim-0.10') == 1 and vim.islist or vim.tbl_islist + +return MiniSnippets diff --git a/readmes/mini-snippets.md b/readmes/mini-snippets.md new file mode 100644 index 00000000..3ca0b0fc --- /dev/null +++ b/readmes/mini-snippets.md @@ -0,0 +1,376 @@ + + + +[![GitHub license](https://badgen.net/github/license/echasnovski/mini.nvim)](https://github.com/echasnovski/mini.nvim/blob/main/LICENSE) + + +### Manage and expand snippets + +See more details in [Features](#features) and [help file](../doc/mini-snippets.txt). + +--- + +⦿ This is a part of [mini.nvim](https://github.com/echasnovski/mini.nvim) library. Please use [this link](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-snippets.md) if you want to mention this module. + +⦿ All contributions (issues, pull requests, discussions, etc.) are done inside of 'mini.nvim'. + +⦿ See the repository page to learn about common design principles and configuration recipes. + +--- + +If you want to help this project grow but don't know where to start, check out [contributing guides of 'mini.nvim'](https://github.com/echasnovski/mini.nvim/blob/main/CONTRIBUTING.md) or leave a Github star for 'mini.nvim' project and/or any its standalone Git repositories. + +## Demo + +https://github.com/user-attachments/assets/0c885d2c-7f4c-4650-919a-7881e1110cec + +## Features + +- Manage snippet collection by adding it explicitly or with a flexible set of performant built-in loaders. See `:h MiniSnippets.gen_loader`. + +- Configured snippets are efficiently resolved before every expand based on current local context. This, for example, allows using different snippets in different local tree-sitter languages (like in markdown code blocks). See `:h MiniSnippets.default_prepare()`. + +- Match which snippet to insert based on the currently typed text. Supports both exact and fuzzy matching. See `:h MiniSnippets.default_match()`. + +- Select from several matched snippets via `vim.ui.select()`. See `:h MiniSnippets.default_select()`. + +- Insert, jump, and edit during snippet session in a configurable manner: + - Configurable mappings for jumping and stopping. + - Jumping wraps around the tabstops for easier navigation. + - Easy to reason rules for when session automatically stops. + - Text synchronization of linked tabstops. + - Dynamic tabstop state visualization (current/visited/unvisited, etc.) + - Inline visualization of empty tabstops (requires Neovim>=0.10). + - Works inside comments by preserving comment leader on new lines. + - Supports nested sessions (expand snippet while there is an one active). + + See `:h MiniSnippets.default_insert()`. + +- Exported function to parse snippet body into easy-to-reason data structure. See `:h MiniSnippets.parse()`. + +Notes: +- It does not set up any snippet collection by default. Explicitly populate `config.snippets` to have snippets to match from. +- It does not come with a built-in snippet collection. It is expected from users to add their own snippets, manually or with a dedicated plugin(s). +- It does not support variable/tabstop transformations in default snippet session. This requires ECMAScript Regular Expression parser which can not be implemented concisely. + +Sources with more details: +- [Overview](#overview) +- `:h MiniSnippets-glossary` +- `:h MiniSnippets-examples` + +## Dependencies + +This module doesn't come with snippet collection. Either create it manually or install a dedicated plugin. For example, [rafamadriz/friendly-snippets](https://github.com/rafamadriz/friendly-snippets). + +## Quickstart + +- Use the following setup: + + ```lua + local gen_loader = require('mini.snippets').gen_loader + require('mini.snippets').setup({ + snippets = { + -- Load custom file with global snippets first (adjust for Windows) + gen_loader.from_file('~/.config/nvim/snippets/global.json'), + + -- Load snippets based on current language by reading files from + -- "snippets/" subdirectories from 'runtimepath' directories. + gen_loader.from_lang(), + }, + }) + ``` + + This setup allows having single file with custom "global" snippets (will be present in every buffer) and snippets which will be loaded based on the local language (see `:h MiniSnippets.gen_loader.from_lang()`). + + Create language snippets manually (like by creating and populating '`$XDG_CONFIG_HOME`/nvim/snippets/lua.json' file) or by installing dedicated snippet collection plugin (like [rafamadriz/friendly-snippets](https://github.com/rafamadriz/friendly-snippets)). + +- Open Neovim in a file with dedicated language (like 'init.lua' from your config) and press ``. + +The best way to grasp the design of snippet management and expansion is to +try them out yourself. Here are extra steps for a basic demo: + +- Create 'snippets/global.json' file in the config directory with the content: + + ```json + { + "Basic": { "prefix": "ba", "body": "T1=$1 T2=$2 T0=$0" }, + "Placeholders": { "prefix": "pl", "body": "T1=${1:aa}\nT2=${2:<$1>}" }, + "Choices": { "prefix": "ch", "body": "T1=${1|a,b|} T2=${2|c,d|}" }, + "Linked": { "prefix": "li", "body": "T1=$1\nT1=$1" }, + "Variables": { "prefix": "va", "body": "Runtime: $VIMRUNTIME\n" }, + "Complex": { + "prefix": "co", + "body": [ "T2=${2:$RANDOM}", "T1=${1:<$2>}", "T2=$2", "T1=$1" ] + } + } + ``` +- Open Neovim. Type each snippet prefix and press `` (even if there is still active session). Explore from there. + +## Overview + +Snippet is a template for a frequently used text. Typical workflow is to type snippet's (configurable) prefix and expand it into a snippet session: add some pre-defined text and allow user to interactively change/add at certain places. + +This overview assumes default config for mappings and expand. See `:h MiniSnippets.config` and `:h MiniSnippets-examples` for more details. + +### Snippet structure + +Snippet consists from three parts: +- `Prefix` - identifier used to match against current text. +- `Body` - actually inserted content with appropriate syntax. +- `Desc` - description in human readable form. + +Example: `{ prefix = 'tis', body = 'This is snippet', desc = 'Snip' }` +Typing `tis` and pressing "expand" mapping (`` by default) will remove "tis", add "This is snippet", and place cursor at the end in Insert mode. + +### Syntax + +Inserting just text after typing smaller prefix is already powerful enough. +For more flexibility, snippet body can be formatted in a special way to +provide extra features. This module implements support for syntax defined +in [LSP specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#snippet_syntax) (with small deviations). + +A quick overview of basic syntax features: + +- Tabstops are snippet parts meant for interactive editing at their location. They are denoted as `$1`, `$2`, etc. + + Navigating between them is called "jumping" and is done in numerical order of tabstop identifiers by pressing special keys: `` and `` to jump to next and previous tabstop respectively. + + Special tabstop `$0` is called "final tabstop": it is used to decide when snippet session is automatically stopped and is visited last during jumping. + + Example: `T1=$1 T2=$2 T0=$0` is expanded as `T1= T2= T0=` with three tabstops. + +- Tabstop can have placeholder: a text used if tabstop is not yet edited. Text is preserved if no editing is done. It follows this same syntax, which means it can itself contain tabstops with placeholders (i.e. be nested). Tabstop with placeholder is denoted as `${1:placeholder}` (`$1` is `${1:}`). + + Example: `T1=${1:text} T2=${2:<$1>}` is expanded as `T1=text T2=`; + typing `x` at first placeholder results in `T1=x T2=`; + jumping once and typing `y` results in `T1=x T2=y`. + +- There can be several tabstops with same identifier. They are linked and updated in sync during text editing. Can also have different placeholders; they are forced to be the same as in the first (from left to right) tabstop. + + Example: `T1=${1:text} T1=$1` is expanded as `T1=text T1=text`; + typing `x` at first placeholder results in `T1=x T1=x`. + +- Tabstop can also have choices: suggestions about tabstop text. It is denoted as `${1|a,b,c|}`. Choices are shown (with `:h ins-completion` like interface) after jumping to tabstop. First choice is used as placeholder. + + Example: `T1=${1|left,right|}` is expanded as `T1=left`. + +- Variables can be used to automatically insert text without user interaction. As tabstops, each one can have a placeholder which is used if variable is not defined. There is a special set of variables describing editor state. + + Example: `V1=$TM_FILENAME V2=${NOTDEFINED:placeholder}` is expanded as + `V1=current-file-basename V2=placeholder`. + +There are several differences LSP specification: not supporting variable transformations, wider set of supported special variables, and couple more. For more details see `:h MiniSnippets-syntax-specification`. + +There is a `:h MiniSnippets.parse()` function for programmatically parsing +snippet body into a comprehensible data structure. + +### Expand + +Using snippets is done via what is called "expanding". It goes like this: +- Type snippet prefix or its recognizable part. +- Press `` to expand. It will perform the following steps: + - Prepare available snippets in current context (buffer + local language). + This allows having general function loaders in snippet setup. + - Match text to the left of cursor with available prefixes. It first tries + to do exact match and falls back to fuzzy matching. + - If there are several matches, use `vim.ui.select()` to choose one. + - Insert single matching snippet. If snippet contains tabstops, start + snippet session. + +For more details about each step see: +- `:h MiniSnippets.default_prepare()` +- `:h MiniSnippets.default_match()` +- `:h MiniSnippets.default_select()` +- `:h MiniSnippets.default_insert()` + +Snippet session allows interactive editing at tabstop locations: + +- All tabstop locations are visualized depending on tabstop "state" (whether it is current/visited/unvisited/final and whether it was already edited). + + Empty tabstops are visualized with inline virtual text (`•` / `∎` for regular/final tabstops). It is removed after session is stopped. + +- Start session at first tabstop. Type text to replace placeholder. When finished with current tabstop, jump to next with ``. Repeat. If changed mind about some previous tabstop, jump back with ``. Jumping also wraps around the edge (first tabstop is next after final). + +- Starting another snippet session while there is one active is allowed. This creates nested sessions: current is suspended, new one is started. After newly created is stopped, the suspended one is resumed. + +- Stop session manually by pressing `` or it will be done automatically: either by making text edit or exiting in Normal mode when final tabstop is current. If snippet doesn't explicitly define final tabstop, it is added at the end of the snippet. + +For more details about snippet session see `:h MiniSnippets-session`. + +### Management + +**Important**: Out of the box 'mini.snippets' doesn't load any snippets, it should be done explicitly inside `:h MiniSnippets.setup()` following `:h MiniSnippets.config`. + +The suggested approach to snippet management is to create dedicated files with snippet data and load them through function loaders in `config.snippets`. +See [Quickstart](#quickstart) for basic (yet capable) snippet management config. + +General idea of supported files is to have at least out of the box experience +with common snippet collections. Namely [rafamadriz/friendly-snippets](https://github.com/rafamadriz/friendly-snippets). + +The following files are supported: + +- Extensions: + - Read/decoded as JSON object: `*.json`, `*.code-snippets` + - Executed as Lua file and uses returned value: `*.lua` + +- Content: + - Dict-like: object in JSON; returned table in Lua; no order guarantees. + - Array-like: array in JSON; returned array table in Lua; preserves order. + +Example of file content with a single snippet: +- Lua dict-like: `return { name = { prefix = 'l', body = 'local $1 = $0' } }` +- Lua array-like: `return { { prefix = 'l', body = 'local $1 = $0' } }` +- JSON dict-like: `{ "name": { "prefix": "l", "body": "local $1 = $0" } }` +- JSON array-like: `[ { "prefix": "l", "body": "local $1 = $0" } ]` + +General advice: +- Put files in "snippets" subdirectory of any path in 'runtimepath' (like '`$XDG_CONFIG_HOME`/nvim/snippets/global.json'). This is compatible with `:h MiniSnippets.gen_loader.from_runtime()`. +- Prefer `*.json` files with dict-like content if you want more cross-platfrom setup. Otherwise use `*.lua` files with array-like content. + +Notes: +- There is no built-in support for VSCode-like "package.json" files. Define structure manually in `:h MiniSnippets.setup()` via built-in or custom loaders. +- There is no built-in support for `scope` field of snippet data. Snippets are expected to be manually separated into smaller files and loaded on demand. + +For supported snippet syntax see `:h MiniSnippets-syntax-specification` or [Syntax](#syntax). + +## Installation + +This plugin can be installed as part of 'mini.nvim' library (**recommended**) or as a standalone Git repository. + +During beta-testing phase there is only one branch to install from: + + +- `main` (default, **recommended**) will have latest development version of plugin. All changes since last stable release should be perceived as being in beta testing phase (meaning they already passed alpha-testing and are moderately settled). + + +Here are code snippets for some common installation methods (use only one): + +
+With mini.deps + + + + + + + + + + + + + + + + + + + + + +
Github repo Branch Code snippet
'mini.nvim' library Main Follow recommended 'mini.deps' installation
Standalone plugin Main add('echasnovski/mini.snippets')
+
+ +
+With folke/lazy.nvim + + + + + + + + + + + + + + + + + + + + + + +
Github repo Branch Code snippet
'mini.nvim' library Main { 'echasnovski/mini.nvim', version = false },
Standalone plugin Main { 'echasnovski/mini.snippets', version = false },
+
+ +
+With junegunn/vim-plug + + + + + + + + + + + + + + + + + + + + + + +
Github repo Branch Code snippet
'mini.nvim' library Main Plug 'echasnovski/mini.nvim'
Standalone plugin Main Plug 'echasnovski/mini.snippets'
+
+ +
+ +**Important**: don't forget to call `require('mini.snippets').setup()` with non-empty `snippets` to have snippets to match from. + +**Note**: if you are on Windows, there might be problems with too long file paths (like `error: unable to create file : Filename too long`). Try doing one of the following: +- Enable corresponding git global config value: `git config --system core.longpaths true`. Then try to reinstall. +- Install plugin in other place with shorter path. + +## Default config + +```lua +-- No need to copy this inside `setup()`. Will be used automatically. +{ + -- Array of snippets and loaders (see |MiniSnippets.config| for details). + -- Nothing is defined by default. Add manually to have snippets to match. + snippets = {}, + + -- Module mappings. Use `''` (empty string) to disable one. + mappings = { + -- Expand snippet at cursor position. Created globally in Insert mode. + expand = '', + + -- Interact with default `expand.insert` session. + -- Created for the duration of active session(s) + jump_next = '', + jump_prev = '', + stop = '', + }, + + -- Functions describing snippet expansion. If `nil`, default values + -- are `MiniSnippets.default_()`. + expand = { + -- Resolve raw config snippets at context + prepare = nil, + -- Match resolved snippets at cursor position + match = nil, + -- Possibly choose among matched snippets + select = nil, + -- Insert selected snippet + insert = nil, + }, +} +``` + +## Similar plugins + +- [L3MON4D3/LuaSnip](https://github.com/L3MON4D3/LuaSnip) +- Built-in snippet expansion in Neovim>=0.10, see `:h vim.snippet` (doesn't provide snippet management, only snippet expansion). +- [rafamadriz/friendly-snippets](https://github.com/rafamadriz/friendly-snippets) (a curated collection of snippet files) diff --git a/scripts/dual_release.sh b/scripts/dual_release.sh index d1e387df..4e7faa5e 100755 --- a/scripts/dual_release.sh +++ b/scripts/dual_release.sh @@ -88,6 +88,7 @@ release_module "operators" release_module "pairs" release_module "pick" release_module "sessions" +release_module "snippets" release_module "splitjoin" release_module "starter" release_module "statusline" diff --git a/scripts/dual_sync.sh b/scripts/dual_sync.sh index 381f2a24..7288449a 100755 --- a/scripts/dual_sync.sh +++ b/scripts/dual_sync.sh @@ -94,6 +94,7 @@ sync_module "operators" sync_module "pairs" sync_module "pick" sync_module "sessions" +sync_module "snippets" sync_module "splitjoin" sync_module "starter" sync_module "statusline" diff --git a/scripts/minidoc.lua b/scripts/minidoc.lua index cb174eda..74ce636c 100644 --- a/scripts/minidoc.lua +++ b/scripts/minidoc.lua @@ -36,6 +36,7 @@ local modules = { 'pairs', 'pick', 'sessions', + 'snippets', 'splitjoin', 'starter', 'statusline', diff --git a/tests/dir-snippets/.styluaignore b/tests/dir-snippets/.styluaignore new file mode 100644 index 00000000..cfe7bc91 --- /dev/null +++ b/tests/dir-snippets/.styluaignore @@ -0,0 +1 @@ +bad-file-cant-execute.lua diff --git a/tests/dir-snippets/bad-file-cant-decode.json b/tests/dir-snippets/bad-file-cant-decode.json new file mode 100644 index 00000000..dfd95b52 --- /dev/null +++ b/tests/dir-snippets/bad-file-cant-decode.json @@ -0,0 +1 @@ +{ "name" = 1 diff --git a/tests/dir-snippets/bad-file-cant-execute.lua b/tests/dir-snippets/bad-file-cant-execute.lua new file mode 100644 index 00000000..8caa7def --- /dev/null +++ b/tests/dir-snippets/bad-file-cant-execute.lua @@ -0,0 +1 @@ +return { diff --git a/tests/dir-snippets/bad-file-not-dict-object.json b/tests/dir-snippets/bad-file-not-dict-object.json new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/dir-snippets/bad-file-not-dict-object.json @@ -0,0 +1 @@ +1 diff --git a/tests/dir-snippets/bad-file-not-table-return.lua b/tests/dir-snippets/bad-file-not-table-return.lua new file mode 100644 index 00000000..a4325f62 --- /dev/null +++ b/tests/dir-snippets/bad-file-not-table-return.lua @@ -0,0 +1 @@ +return 1 diff --git a/tests/dir-snippets/file-array.code-snippets b/tests/dir-snippets/file-array.code-snippets new file mode 100644 index 00000000..80592f96 --- /dev/null +++ b/tests/dir-snippets/file-array.code-snippets @@ -0,0 +1,29 @@ +[ + { + "prefix": "lua_a", + "body": "LUA_A=$1", + "desc": "Desc LUA_A" + }, + { + "prefix": "lua_b", + "body": "LUA_B=$1", + "description": "Desc LUA_B" + }, + { + "body": "LUA_C=$1" + }, + { + "prefix": "d", + "body": "D1=$1" + }, + { + "prefix": "d", + "desc": "Dupl2" + }, + + { + "prefix": 1, + "desc": "Not snippet data #1" + }, + 2 +] diff --git a/tests/dir-snippets/file-array.json b/tests/dir-snippets/file-array.json new file mode 100644 index 00000000..80592f96 --- /dev/null +++ b/tests/dir-snippets/file-array.json @@ -0,0 +1,29 @@ +[ + { + "prefix": "lua_a", + "body": "LUA_A=$1", + "desc": "Desc LUA_A" + }, + { + "prefix": "lua_b", + "body": "LUA_B=$1", + "description": "Desc LUA_B" + }, + { + "body": "LUA_C=$1" + }, + { + "prefix": "d", + "body": "D1=$1" + }, + { + "prefix": "d", + "desc": "Dupl2" + }, + + { + "prefix": 1, + "desc": "Not snippet data #1" + }, + 2 +] diff --git a/tests/dir-snippets/file-array.lua b/tests/dir-snippets/file-array.lua new file mode 100644 index 00000000..e6b45254 --- /dev/null +++ b/tests/dir-snippets/file-array.lua @@ -0,0 +1,12 @@ +return { + { prefix = 'lua_a', body = 'LUA_A=$1', desc = 'Desc LUA_A' }, + { prefix = 'lua_b', body = 'LUA_B=$1', description = 'Desc LUA_B' }, + + { prefix = 1, desc = 'Not snippet data #1' }, + + { prefix = nil, body = 'LUA_C=$1' }, + { prefix = 'd', body = 'D1=$1' }, + { prefix = 'd', body = nil, desc = 'Dupl2' }, + + 2, +} diff --git a/tests/dir-snippets/file-dict.code-snippets b/tests/dir-snippets/file-dict.code-snippets new file mode 100644 index 00000000..3884b03b --- /dev/null +++ b/tests/dir-snippets/file-dict.code-snippets @@ -0,0 +1,29 @@ +{ + "name_a": { + "prefix": "lua_a", + "body": "LUA_A=$1", + "desc": "Desc LUA_A" + }, + "name_b": { + "prefix": "lua_b", + "body": "LUA_B=$1", + "description": "Desc LUA_B" + }, + "name_c": { + "body": "LUA_C=$1" + }, + "dupl1": { + "prefix": "d", + "body": "D1=$1" + }, + "dupl2": { + "prefix": "d", + "desc": "Dupl2" + }, + + "problem_1": { + "prefix": 1, + "desc": "Not snippet data #1" + }, + "problem_2": 2 +} diff --git a/tests/dir-snippets/file-dict.json b/tests/dir-snippets/file-dict.json new file mode 100644 index 00000000..3884b03b --- /dev/null +++ b/tests/dir-snippets/file-dict.json @@ -0,0 +1,29 @@ +{ + "name_a": { + "prefix": "lua_a", + "body": "LUA_A=$1", + "desc": "Desc LUA_A" + }, + "name_b": { + "prefix": "lua_b", + "body": "LUA_B=$1", + "description": "Desc LUA_B" + }, + "name_c": { + "body": "LUA_C=$1" + }, + "dupl1": { + "prefix": "d", + "body": "D1=$1" + }, + "dupl2": { + "prefix": "d", + "desc": "Dupl2" + }, + + "problem_1": { + "prefix": 1, + "desc": "Not snippet data #1" + }, + "problem_2": 2 +} diff --git a/tests/dir-snippets/file-dict.lua b/tests/dir-snippets/file-dict.lua new file mode 100644 index 00000000..65176155 --- /dev/null +++ b/tests/dir-snippets/file-dict.lua @@ -0,0 +1,10 @@ +return { + name_a = { prefix = 'lua_a', body = 'LUA_A=$1', desc = 'Desc LUA_A' }, + name_b = { prefix = 'lua_b', body = 'LUA_B=$1', description = 'Desc LUA_B' }, + name_c = { prefix = nil, body = 'LUA_C=$1', desc = nil }, + dupl1 = { prefix = 'd', body = 'D1=$1', desc = nil }, + dupl2 = { prefix = 'd', body = nil, desc = 'Dupl2' }, + + problem_a = { prefix = 1, desc = 'Not snippet data #1' }, + problem_b = 2, +} diff --git a/tests/dir-snippets/file.many.dots.lua b/tests/dir-snippets/file.many.dots.lua new file mode 100644 index 00000000..a59b695a --- /dev/null +++ b/tests/dir-snippets/file.many.dots.lua @@ -0,0 +1 @@ +return { { prefix = 'a', body = 'A=$1' } } diff --git a/tests/dir-snippets/file.notsupported b/tests/dir-snippets/file.notsupported new file mode 100644 index 00000000..87d22cf2 --- /dev/null +++ b/tests/dir-snippets/file.notsupported @@ -0,0 +1,6 @@ +{ + "name": { + "prefix": "a", + "body": "A=$1", + } +} diff --git a/tests/dir-snippets/snippets/lua.json b/tests/dir-snippets/snippets/lua.json new file mode 100644 index 00000000..04604833 --- /dev/null +++ b/tests/dir-snippets/snippets/lua.json @@ -0,0 +1 @@ +{ "snippets/lua.json": { "prefix": "a", "body": "A=$1" } } diff --git a/tests/dir-snippets/snippets/lua.lua b/tests/dir-snippets/snippets/lua.lua new file mode 100644 index 00000000..3f3d4835 --- /dev/null +++ b/tests/dir-snippets/snippets/lua.lua @@ -0,0 +1 @@ +return { ['snippets/lua.lua'] = { prefix = 'b', body = 'B=$1' } } diff --git a/tests/dir-snippets/snippets/nested/lua.json b/tests/dir-snippets/snippets/nested/lua.json new file mode 100644 index 00000000..e5432781 --- /dev/null +++ b/tests/dir-snippets/snippets/nested/lua.json @@ -0,0 +1 @@ +{ "snippets/nested/lua.json": { "prefix": "g", "body": "G=$1" } } diff --git a/tests/dir-snippets/snippets/nested/lua.lua b/tests/dir-snippets/snippets/nested/lua.lua new file mode 100644 index 00000000..e19111b1 --- /dev/null +++ b/tests/dir-snippets/snippets/nested/lua.lua @@ -0,0 +1 @@ +return { ['snippets/nested/lua.lua'] = { prefix = 'h', body = 'H=$1' } } diff --git a/tests/dir-snippets/subdir/snippets/lua.code-snippets b/tests/dir-snippets/subdir/snippets/lua.code-snippets new file mode 100644 index 00000000..c7a5549a --- /dev/null +++ b/tests/dir-snippets/subdir/snippets/lua.code-snippets @@ -0,0 +1 @@ +{ "subdir/snippets/lua.code-snippets": { "prefix": "e", "body": "E=$1" } } diff --git a/tests/dir-snippets/subdir/snippets/lua.json b/tests/dir-snippets/subdir/snippets/lua.json new file mode 100644 index 00000000..bd0a0412 --- /dev/null +++ b/tests/dir-snippets/subdir/snippets/lua.json @@ -0,0 +1 @@ +{ "subdir/snippets/lua.json": { "prefix": "c", "body": "C=$1" } } diff --git a/tests/dir-snippets/subdir/snippets/lua/deeper/another.json b/tests/dir-snippets/subdir/snippets/lua/deeper/another.json new file mode 100644 index 00000000..9ed65c8f --- /dev/null +++ b/tests/dir-snippets/subdir/snippets/lua/deeper/another.json @@ -0,0 +1 @@ +{ "subdir/snippets/lua/deeper/another.json": { "prefix": "f", "body": "F=$1" } } diff --git a/tests/dir-snippets/subdir/snippets/lua/file.json b/tests/dir-snippets/subdir/snippets/lua/file.json new file mode 100644 index 00000000..7f882264 --- /dev/null +++ b/tests/dir-snippets/subdir/snippets/lua/file.json @@ -0,0 +1 @@ +{ "subdir/snippets/lua/file.json": { "prefix": "e", "body": "E=$1" } } diff --git a/tests/dir-snippets/subdir/snippets/lua/snips.lua b/tests/dir-snippets/subdir/snippets/lua/snips.lua new file mode 100644 index 00000000..8b749d19 --- /dev/null +++ b/tests/dir-snippets/subdir/snippets/lua/snips.lua @@ -0,0 +1 @@ +return { ['subdir/snippets/lua/snips.lua'] = { prefix = 'd', body = 'D=$1' } } diff --git a/tests/helpers.lua b/tests/helpers.lua index 99db4c3a..d7ab21cb 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -35,6 +35,28 @@ Helpers.expect.equality_approx = MiniTest.new_expectation( function(x, y, tol) return string.format('Left: %s\nRight: %s\nTolerance: %s', vim.inspect(x), vim.inspect(y), tol) end ) +Helpers.make_partial_tbl = function(tbl, ref) + local res = {} + for k, v in pairs(ref) do + res[k] = (type(tbl[k]) == 'table' and type(v) == 'table') and Helpers.make_partial_tbl(tbl[k], v) or tbl[k] + end + for i = 1, #tbl do + if ref[i] == nil then res[i] = tbl[i] end + end + return res +end + +Helpers.expect.equality_partial_tbl = MiniTest.new_expectation( + 'equality of tables only in reference fields', + function(x, y) + if type(x) == 'table' and type(y) == 'table' then x = Helpers.make_partial_tbl(x, y, {}) end + return vim.deep_equal(x, y) + end, + function(x, y) + return string.format('Left: %s\nRight: %s', vim.inspect(Helpers.make_partial_tbl(x, y, {})), vim.inspect(y)) + end +) + -- Monkey-patch `MiniTest.new_child_neovim` with helpful wrappers Helpers.new_child_neovim = function() local child = MiniTest.new_child_neovim() diff --git a/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---no-affect-of-'exausted'-popup-during-jump b/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---no-affect-of-'exausted'-popup-during-jump new file mode 100644 index 00000000..20e3d36f --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---no-affect-of-'exausted'-popup-during-jump @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|abc ax x∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,9 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000110230000000000000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---squeezed-tabstops b/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---squeezed-tabstops new file mode 100644 index 00000000..745dcfc7 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---squeezed-tabstops @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|abcxabcxabc∎ +2|abcxabc +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- Back at original + +-|---------|---------|---------|---------| +1|0001000100023333333333333333333333333333 +2|4444444444444445555555555555555555555555 +3|5555555555555555555555555555555555555555 +4|5555555555555555555555555555555555555555 +5|5555555555555555555555555555555555555555 +6|5555555555555555555555555555555555555555 +7|6666666666666666666666666666666666666666 +8|7778888888888888888999999999999999999999 diff --git a/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---squeezed-tabstops-002 b/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---squeezed-tabstops-002 new file mode 100644 index 00000000..d07d4498 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Interaction-with-built-in-completion---squeezed-tabstops-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|abcxyabcxyabc∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,6 All +8|-- Back at original + +-|---------|---------|---------|---------| +1|0001100011000233333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6667777777777777777888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---autostop---is-not-triggered-if-final-tabstop-is-not-current b/tests/screenshots/tests-test_snippets.lua---Session---autostop---is-not-triggered-if-final-tabstop-is-not-current new file mode 100644 index 00000000..13df3568 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---autostop---is-not-triggered-if-final-tabstop-is-not-current @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=• T0=new∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,11-12 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000000020000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---choices---are-relevant-to-text b/tests/screenshots/tests-test_snippets.lua---Session---choices---are-relevant-to-text new file mode 100644 index 00000000..0e813448 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---choices---are-relevant-to-text @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=aa T2=d∎ +2|~ dd +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,11 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100002300000000000000000000000000000 +2|4444444455555555555555554444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|6666666666666666666666666666666666666666 +8|7777777777778888888888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---choices---are-relevant-to-text-002 b/tests/screenshots/tests-test_snippets.lua---Session---choices---are-relevant-to-text-002 new file mode 100644 index 00000000..e34966f9 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---choices---are-relevant-to-text-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=aa T2=c∎ +2|~ cc +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,11 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100002300000000000000000000000000000 +2|4444444455555555555555554444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|6666666666666666666666666666666666666666 +8|7777777777778888888888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---choices---work-with-default-'completeopt' b/tests/screenshots/tests-test_snippets.lua---Session---choices---work-with-default-'completeopt' new file mode 100644 index 00000000..16db4877 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---choices---work-with-default-'completeopt' @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=aa T2=dd∎ +2|~ aa +3|~ bb +4|~ +5|~ +6|~ +7|[No Name] [+] 1,6 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100002230000000000000000000000000000 +2|4455555555555555554444444444444444444444 +3|4466666666666666664444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|7777777777777777777777777777777777777777 +8|8888888888889999999999999999999999999999 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---choices---work-with-default-'completeopt'-002 b/tests/screenshots/tests-test_snippets.lua---Session---choices---work-with-default-'completeopt'-002 new file mode 100644 index 00000000..16db4877 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---choices---work-with-default-'completeopt'-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=aa T2=dd∎ +2|~ aa +3|~ bb +4|~ +5|~ +6|~ +7|[No Name] [+] 1,6 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100002230000000000000000000000000000 +2|4455555555555555554444444444444444444444 +3|4466666666666666664444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|7777777777777777777777777777777777777777 +8|8888888888889999999999999999999999999999 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---choices---works b/tests/screenshots/tests-test_snippets.lua---Session---choices---works new file mode 100644 index 00000000..d800702a --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---choices---works @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=aa T2=dd∎ +2|~ aa +3|~ bb +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100002230000000000000000000000000000 +2|4455555555555555554444444444444444444444 +3|4455555555555555554444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|6666666666666666666666666666666666666666 +8|7777777777778888888888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---choices---works-002 b/tests/screenshots/tests-test_snippets.lua---Session---choices---works-002 new file mode 100644 index 00000000..966fa38b --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---choices---works-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=• T2=dd∎ +2|~ aa +3|~ bb +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4-5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000022300000000000000000000000000000 +2|4445555555555555555444444444444444444444 +3|4445555555555555555444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|6666666666666666666666666666666666666666 +8|7777777777778888888888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop b/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop new file mode 100644 index 00000000..becea174 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= •• ∎ • +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111111022030200000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop-002 b/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop-002 new file mode 100644 index 00000000..3c9d19be --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= •• ∎ • +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,8-9 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111221022030400000000000000000000000 +2|5555555555555555555555555555555555555555 +3|5555555555555555555555555555555555555555 +4|5555555555555555555555555555555555555555 +5|5555555555555555555555555555555555555555 +6|5555555555555555555555555555555555555555 +7|6666666666666666666666666666666666666666 +8|7777777777778888888888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop-003 b/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop-003 new file mode 100644 index 00000000..c5a425ee --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---highlighting---uses-same-highlighting-for-whole-placeholder-for-current-tabstop-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= •• ∎ • +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,8-10 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111121012030200000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing new file mode 100644 index 00000000..07215c3f --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=ab_T1=ab∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,6 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100001120000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing-002 b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing-002 new file mode 100644 index 00000000..15b61261 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=ab +2|_T1=ab +3|∎ +4|~ +5|~ +6|~ +7|[No Name] [+] 2,1 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100000000000000000000000000000000000 +2|0000110000000000000000000000000000000000 +3|2000000000000000000000000000000000000000 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing-003 b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing-003 new file mode 100644 index 00000000..0000b6b2 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---are-updated-immediately-when-typing-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=ab +2|c_T1=ab +3|c∎ +4|~ +5|~ +6|~ +7|[No Name] [+] 2,2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100000000000000000000000000000000000 +2|1000011000000000000000000000000000000000 +3|1200000000000000000000000000000000000000 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node new file mode 100644 index 00000000..636b5aed --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= T2=• T1=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111110000200001111113000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node-002 b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node-002 new file mode 100644 index 00000000..41525c19 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= T2=• T1=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,8-9 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111210000200001111213000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node-003 b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node-003 new file mode 100644 index 00000000..d87b59d2 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---jumps-to-the-first-node-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x T2=• T1=x∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,9-10 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000020000130000000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---validates-that-session-data-is-valid b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---validates-that-session-data-is-valid new file mode 100644 index 00000000..9a793cf1 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---validates-that-session-data-is-valid @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x +2|T0= +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000000000000000000000000000000000000000 +2|0000000000000000000000000000000000000000 +3|1111111111111111111111111111111111111111 +4|1111111111111111111111111111111111111111 +5|1111111111111111111111111111111111111111 +6|1111111111111111111111111111111111111111 +7|2222222222222222222222222222222222222222 +8|3333333333334444444444444444444444444444 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---validates-that-session-data-is-valid-002 b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---validates-that-session-data-is-valid-002 new file mode 100644 index 00000000..24c13588 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---linked-tabstops---validates-that-session-data-is-valid-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,3 All +8| + +-|---------|---------|---------|---------| +1|0000000000000000000000000000000000000000 +2|1111111111111111111111111111111111111111 +3|1111111111111111111111111111111111111111 +4|1111111111111111111111111111111111111111 +5|1111111111111111111111111111111111111111 +6|1111111111111111111111111111111111111111 +7|2222222222222222222222222222222222222222 +8|3333333333333333333333333333333333333333 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---can-be-done-outside-of-current-session-region b/tests/screenshots/tests-test_snippets.lua---Session---nesting---can-be-done-outside-of-current-session-region new file mode 100644 index 00000000..5e73c17e --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---can-be-done-outside-of-current-session-region @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= T0= +2| +3|U1=• U0=∎ +4|~ +5|~ +6|~ +7|[No Name] [+] 3,4-5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000000000000000000000000000000000000000 +2|0000000000000000000000000000000000000000 +3|0001000020000000000000000000000000000000 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---does-not-nest-if-no-tabstops-in-new-session b/tests/screenshots/tests-test_snippets.lua---Session---nesting---does-not-nest-if-no-tabstops-in-new-session new file mode 100644 index 00000000..f433a8b4 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---does-not-nest-if-no-tabstops-in-new-session @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=just text T0=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,13 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111111110000200000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events new file mode 100644 index 00000000..889b7c83 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=• T0=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4-5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000020000000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-002 b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-002 new file mode 100644 index 00000000..4ec544cd --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1=• U0=∎ T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,7-8 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000001000020000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-003 b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-003 new file mode 100644 index 00000000..d3a38f22 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1=V1=vvv V0=∎ U0= T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,13 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000000001110000200000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-004 b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-004 new file mode 100644 index 00000000..836e7172 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-004 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1=V1=vvv V0= U0=∎ T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,13 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000001111111111000020000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-005 b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-005 new file mode 100644 index 00000000..ec6d06e9 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---nesting---works-and-triggers-events-005 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1=V1=vvv V0= U0= T0=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,13 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111111111111111100002000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Session---persists-after-`-edit` b/tests/screenshots/tests-test_snippets.lua---Session---persists-after-`-edit` new file mode 100644 index 00000000..06bc7682 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Session---persists-after-`-edit` @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=• T0=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7| and T2=•∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111110000000023000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-002 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-002 new file mode 100644 index 00000000..39bff448 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x and T2=•∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000000002300000000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-003 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-003 new file mode 100644 index 00000000..755d6600 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x and T2=y∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,14 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000000002300000000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-004 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-004 new file mode 100644 index 00000000..76550bc6 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-004 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= and T2=•∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,8-9 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111210000000023000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-005 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-005 new file mode 100644 index 00000000..24c7a9f4 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-005 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= and T2=x∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,9 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111210000000023000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-006 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-006 new file mode 100644 index 00000000..6449068e --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---intertwined-nested-tabstops-006 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=y and T2=x∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000000002300000000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops new file mode 100644 index 00000000..c8d19ca5 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|••• •• •∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001221231111111111111111111111111111111 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-002 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-002 new file mode 100644 index 00000000..bc63663c --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|••• •• •∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-3 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0112112342222222222222222222222222222222 +2|5555555555555555555555555555555555555555 +3|5555555555555555555555555555555555555555 +4|5555555555555555555555555555555555555555 +5|5555555555555555555555555555555555555555 +6|5555555555555555555555555555555555555555 +7|6666666666666666666666666666666666666666 +8|7777777777778888888888888888888888888888 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-003 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-003 new file mode 100644 index 00000000..c80b1870 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|••• •• •∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0012012132222222222222222222222222222222 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-004 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-004 new file mode 100644 index 00000000..625cf690 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-004 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|••a •a a∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2-4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0012012132222222222222222222222222222222 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-005 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-005 new file mode 100644 index 00000000..223c2c03 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-005 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•b b a∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2-3 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0121203222222222222222222222222222222222 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-006 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-006 new file mode 100644 index 00000000..3747f4d9 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---nested-empty-tabstops-006 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|c b a∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0121231111111111111111111111111111111111 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops new file mode 100644 index 00000000..577a3eda --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•••••∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001123333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-002 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-002 new file mode 100644 index 00000000..6cc9ba51 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|aaa••∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001123333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-003 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-003 new file mode 100644 index 00000000..9c490e49 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|aaabb∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001123333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-004 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-004 new file mode 100644 index 00000000..aa771a8a --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-004 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|aaa••∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4-5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001123333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-005 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-005 new file mode 100644 index 00000000..577a3eda --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-consecutive-tabstops-005 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•••••∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001123333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops new file mode 100644 index 00000000..906d94ba --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•••••∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0101023333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-002 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-002 new file mode 100644 index 00000000..568d15e2 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|a•a•a∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0101023333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-003 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-003 new file mode 100644 index 00000000..4057464f --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|ababa∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,3 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0101023333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-004 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-004 new file mode 100644 index 00000000..233dac12 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-004 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|a•a•a∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2-3 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0101023333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-005 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-005 new file mode 100644 index 00000000..906d94ba --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-empty-interleaving-tabstops-005 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•••••∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0101023333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders new file mode 100644 index 00000000..4e581703 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•aa•aa•∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0110110233333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders-002 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders-002 new file mode 100644 index 00000000..503e29ed --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|xaaxaax∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0110110233333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders-003 b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders-003 new file mode 100644 index 00000000..8f7d12af --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Tricky-snippets---squashed-linked-tabstops-with-placeholders-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|xyxyx∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,3 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0101023333333333333333333333333333333333 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders new file mode 100644 index 00000000..41de067c --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1= T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111100002222200000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-002 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-002 new file mode 100644 index 00000000..0136bbbe --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000022222000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-003 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-003 new file mode 100644 index 00000000..a2e9e5bb --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,9 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000022222000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-004 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-004 new file mode 100644 index 00000000..49bc8eba --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-004 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x T0=y +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,10 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000000000000000000000000000000000000000 +2|1111111111111111111111111111111111111111 +3|1111111111111111111111111111111111111111 +4|1111111111111111111111111111111111111111 +5|1111111111111111111111111111111111111111 +6|1111111111111111111111111111111111111111 +7|2222222222222222222222222222222222222222 +8|3333333333334444444444444444444444444444 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-005 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-005 new file mode 100644 index 00000000..8d81ef68 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-005 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=aa +2|bb +3| T0=∎ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001100000000000000000000000000000000000 +2|1100000000000000000000000000000000000000 +3|0000200000000000000000000000000000000000 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-006 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-006 new file mode 100644 index 00000000..c4fc1851 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-006 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=x T0=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000020000000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-007 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-007 new file mode 100644 index 00000000..e926cfd7 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---placeholders-007 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|Text placeholder +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,6 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000011111111111000000000000000000000000 +2|2222222222222222222222222222222222222222 +3|2222222222222222222222222222222222222222 +4|2222222222222222222222222222222222222222 +5|2222222222222222222222222222222222222222 +6|2222222222222222222222222222222222222222 +7|3333333333333333333333333333333333333333 +8|4444444444445555555555555555555555555555 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---tabstop b/tests/screenshots/tests-test_snippets.lua---Various-snippets---tabstop new file mode 100644 index 00000000..71bad05c --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---tabstop @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0122222222222222222222222222222222222222 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---Various-snippets---tabstop-002 b/tests/screenshots/tests-test_snippets.lua---Various-snippets---tabstop-002 new file mode 100644 index 00000000..71bad05c --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---Various-snippets---tabstop-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|•∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,1-2 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0122222222222222222222222222222222222222 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---default_insert()---respects-`opts.empty_tabstop`-and-`opts.empty_tabstop_final` b/tests/screenshots/tests-test_snippets.lua---default_insert()---respects-`opts.empty_tabstop`-and-`opts.empty_tabstop_final` new file mode 100644 index 00000000..a1bb41b2 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---default_insert()---respects-`opts.empty_tabstop`-and-`opts.empty_tabstop_final` @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=! T2=! T0=? +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,4-5 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001000020000300000000000000000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---default_insert()---respects-`opts.lookup` b/tests/screenshots/tests-test_snippets.lua---default_insert()---respects-`opts.lookup` new file mode 100644 index 00000000..2f032be1 --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---default_insert()---respects-`opts.lookup` @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|aaa xxx tabstop tabstop •∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,16 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000000011111110111111102300000000000000 +2|4444444444444444444444444444444444444444 +3|4444444444444444444444444444444444444444 +4|4444444444444444444444444444444444444444 +5|4444444444444444444444444444444444444444 +6|4444444444444444444444444444444444444444 +7|5555555555555555555555555555555555555555 +8|6666666666667777777777777777777777777777 diff --git a/tests/screenshots/tests-test_snippets.lua---session.stop()---works b/tests/screenshots/tests-test_snippets.lua---session.stop()---works new file mode 100644 index 00000000..4ec544cd --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---session.stop()---works @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1=• U0=∎ T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,7-8 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000001000020000000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---session.stop()---works-002 b/tests/screenshots/tests-test_snippets.lua---session.stop()---works-002 new file mode 100644 index 00000000..6f841e8d --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---session.stop()---works-002 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1= U0= T0=∎ +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,7 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0001111111000020000000000000000000000000 +2|3333333333333333333333333333333333333333 +3|3333333333333333333333333333333333333333 +4|3333333333333333333333333333333333333333 +5|3333333333333333333333333333333333333333 +6|3333333333333333333333333333333333333333 +7|4444444444444444444444444444444444444444 +8|5555555555556666666666666666666666666666 diff --git a/tests/screenshots/tests-test_snippets.lua---session.stop()---works-003 b/tests/screenshots/tests-test_snippets.lua---session.stop()---works-003 new file mode 100644 index 00000000..9ae26dae --- /dev/null +++ b/tests/screenshots/tests-test_snippets.lua---session.stop()---works-003 @@ -0,0 +1,19 @@ +-|---------|---------|---------|---------| +1|T1=U1= U0= T0= +2|~ +3|~ +4|~ +5|~ +6|~ +7|[No Name] [+] 1,7 All +8|-- INSERT -- + +-|---------|---------|---------|---------| +1|0000000000000000000000000000000000000000 +2|1111111111111111111111111111111111111111 +3|1111111111111111111111111111111111111111 +4|1111111111111111111111111111111111111111 +5|1111111111111111111111111111111111111111 +6|1111111111111111111111111111111111111111 +7|2222222222222222222222222222222222222222 +8|3333333333334444444444444444444444444444 diff --git a/tests/test_snippets.lua b/tests/test_snippets.lua new file mode 100644 index 00000000..890c0f55 --- /dev/null +++ b/tests/test_snippets.lua @@ -0,0 +1,4746 @@ +local helpers = dofile('tests/helpers.lua') + +local child = helpers.new_child_neovim() +local expect, eq, no_eq = helpers.expect, helpers.expect.equality, helpers.expect.no_equality +local eq_partial_tbl = helpers.expect.equality_partial_tbl +local new_set = MiniTest.new_set + +-- Helpers with child processes +--stylua: ignore start +local load_module = function(config) child.mini_load('snippets', config) end +local unload_module = function() child.mini_unload('snippets') end +local set_cursor = function(...) return child.set_cursor(...) end +local get_cursor = function(...) return child.get_cursor(...) end +local set_lines = function(...) return child.set_lines(...) end +local get_lines = function(...) return child.get_lines(...) end +local type_keys = function(...) return child.type_keys(...) end +local sleep = function(ms) helpers.sleep(ms, child) end +local new_buf = function() return child.api.nvim_create_buf(true, false) end +local get_buf = function() return child.api.nvim_get_current_buf() end +local set_buf = function(buf_id) child.api.nvim_set_current_buf(buf_id) end +--stylua: ignore end + +local test_dir = 'tests/dir-snippets' +local test_dir_absolute = vim.fn.fnamemodify(test_dir, ':p'):gsub('\\', '/'):gsub('(.)/$', '%1') + +-- Time constants +local small_time = helpers.get_time_const(10) + +-- Tweak `expect_screenshot()` to test only on Neovim>=0.10 (as it has inline +-- extmarks support). Use `child.expect_screenshot_orig()` for original testing. +child.expect_screenshot_orig = child.expect_screenshot +child.expect_screenshot = function(opts) + if child.fn.has('nvim-0.10') == 0 then return end + child.expect_screenshot_orig(opts) +end + +-- Common test wrappers +local forward_lua = function(fun_str) + local lua_cmd = fun_str .. '(...)' + return function(...) return child.lua_get(lua_cmd, { ... }) end +end + +local get = forward_lua('MiniSnippets.session.get') +local get_all = function() return get(true) end +local jump = forward_lua('MiniSnippets.session.jump') +local stop = forward_lua('MiniSnippets.session.stop') + +-- Common helpers +local get_cur_tabstop = function() return (get() or {}).cur_tabstop end + +local validate_active_session = function() eq(child.lua_get('MiniSnippets.session.get() ~= nil'), true) end +local validate_no_active_session = function() eq(child.lua_get('MiniSnippets.session.get() ~= nil'), false) end +local validate_n_sessions = function(n) eq(child.lua_get('#MiniSnippets.session.get(true)'), n) end + +local validate_pumvisible = function() eq(child.fn.pumvisible(), 1) end +local validate_no_pumvisible = function() eq(child.fn.pumvisible(), 0) end +local validate_pumitems = function(ref) + if #ref == 0 then validate_no_pumvisible() end + if #ref > 0 then validate_pumvisible() end + eq(vim.tbl_map(function(t) return t.word end, child.fn.complete_info().items), ref) +end + +local validate_state = function(mode, lines, cursor) + if mode ~= nil then eq(child.fn.mode(), mode) end + if lines ~= nil then eq(get_lines(), lines) end + if cursor ~= nil then eq(get_cursor(), cursor) end +end + +local mock_select = function(user_chosen_id) + child.lua('_G.user_chosen_id = ' .. user_chosen_id) + child.lua([[ + vim.ui.select = function(items, opts, on_choice) + local format_item = opts.format_item or function(x) return tostring(x) end + _G.select_args = { + items = items, + items_formatted = vim.tbl_map(format_item, items), + prompt = opts.prompt, + kind = opts.kind + } + on_choice(items[_G.user_chosen_id], _G.user_chosen_id) + end + ]]) +end + +local setup_event_log = function() + child.lua([[ + local suffixes = { 'Start', 'Stop', 'Suspend', 'Resume', 'JumpPre', 'Jump' } + local au_events = vim.tbl_map(function(s) return 'MiniSnippetsSession' .. s end, suffixes) + _G.au_log = {} + local log_event = function(args) + table.insert(au_log, { buf_id = args.buf, event = args.match, data = args.data }) + end + vim.api.nvim_create_autocmd('User', { pattern = au_events, callback = log_event }) + ]]) +end + +local get_au_log = function() return child.lua_get('_G.au_log') end + +local clean_au_log = function() return child.lua('_G.au_log = {}') end + +local get_snippet_body = function(session) return (session or get()).insert_args.snippet.body end +local make_snippet_body = function(body) return { insert_args = { snippet = body } } end + +local make_get_extmark = function(session) + local buf_id, ns_id = session.buf_id, session.ns_id + return function(extmark_id) + local data = child.api.nvim_buf_get_extmark_by_id(buf_id, ns_id, extmark_id, { details = true }) + data[3].row, data[3].col = data[1], data[2] + return data[3] + end +end + +local validate_session_nodes_partial = function(session, ref_nodes) + local get_extmark = make_get_extmark(session) + local nodes = vim.deepcopy(session.nodes) + -- Replace `extmark_id` (should be present in every node) with extmark data + local replace_extmarks + replace_extmarks = function(n_arr) + for _, n in ipairs(n_arr) do + n.extmark = get_extmark(n.extmark_id) + n.extmark_id = nil + if n.placeholder ~= nil then replace_extmarks(n.placeholder) end + end + end + replace_extmarks(nodes) + + eq_partial_tbl(nodes, ref_nodes) +end + +local ensure_clean_state = function() + child.lua([[while MiniSnippets.session.get() do MiniSnippets.session.stop() end]]) + -- while get() do stop() end + child.ensure_normal_mode() + set_lines({}) + clean_au_log() +end + +local edit = function(path) + child.cmd('edit ' .. child.fn.fnameescape(path)) + -- Slow context needs a small delay to get things up to date + if helpers.is_slow() then sleep(small_time) end +end + +-- Output test set ============================================================ +local T = new_set({ + hooks = { + pre_case = function() + child.setup() + child.set_size(8, 40) + set_buf(new_buf()) + + -- Mock `vim.notify()` + child.lua([[ + _G.notify_log = {} + local inverse_levels = {} + for k, v in pairs(vim.log.levels) do + inverse_levels[v] = k + end + vim.notify = function(msg, lvl, opts) + table.insert(_G.notify_log, { msg, inverse_levels[lvl], opts }) + end + ]]) + + -- Add helper for easier RPC communication + child.lua([[ + _G.sanitize_object = function(x) + if type(x) == 'function' then return 'function' end + if type(x) == 'table' then + local res = {} + for k, v in pairs(x) do + res[k] = _G.sanitize_object(v) + end + return res + end + return x + end + ]]) + + -- Better interaction with built-in completion + child.o.completeopt = 'menuone,noselect' + + load_module() + end, + post_once = child.stop, + n_retry = helpers.get_n_retry(2), + }, +}) + +-- Unit tests ================================================================= +T['setup()'] = new_set() + +T['setup()']['creates side effects'] = function() + -- Global variable + eq(child.lua_get('type(_G.MiniSnippets)'), 'table') + + -- Highlight groups + child.cmd('hi clear') + child.cmd('hi DiagnosticUnderlineError guisp=#ff0000 gui=underline cterm=underline') + child.cmd('hi DiagnosticUnderlineWarn guisp=#ffff00 gui=undercurl cterm=undercurl') + child.cmd('hi DiagnosticUnderlineInfo guisp=#0000ff gui=underdotted cterm=underline') + child.cmd('hi DiagnosticUnderlineHint guisp=#00ffff gui=underdashed cterm=underdashed') + child.cmd('hi DiagnosticUnderlineOk guifg=#00ff00 guibg=#000000') + load_module() + local has_highlight = function(group, value) expect.match(child.cmd_capture('hi ' .. group), value) end + + has_highlight('MiniSnippetsCurrent', 'gui=underdouble guisp=#ffff00') + has_highlight('MiniSnippetsCurrentReplace', 'gui=underdouble guisp=#ff0000') + has_highlight('MiniSnippetsFinal', 'gui=underdouble') + has_highlight('MiniSnippetsUnvisited', 'gui=underdouble guisp=#00ffff') + has_highlight('MiniSnippetsVisited', 'gui=underdouble guisp=#0000ff') +end + +T['setup()']['creates `config` field'] = function() + eq(child.lua_get('type(_G.MiniSnippets.config)'), 'table') + + -- Check default values + local expect_config = function(field, value) eq(child.lua_get('MiniSnippets.config.' .. field), value) end + + expect_config('snippets', {}) + expect_config('mappings.expand', '') + expect_config('mappings.jump_next', '') + expect_config('mappings.jump_prev', '') + expect_config('mappings.stop', '') + expect_config('expand', { prepare = nil, match = nil, select = nil, insert = nil }) +end + +T['setup()']['respects `config` argument'] = function() + unload_module() + load_module({ snippets = { { prefix = 'a', body = 'axa' } } }) + eq(child.lua_get('MiniSnippets.config.snippets'), { { prefix = 'a', body = 'axa' } }) +end + +T['setup()']['validates `config` argument'] = function() + unload_module() + + local expect_config_error = function(config, name, target_type) + expect.error(load_module, vim.pesc(name) .. '.*' .. vim.pesc(target_type), config) + end + + expect_config_error('a', 'config', 'table') + expect_config_error({ snippets = 1 }, 'snippets', 'table') + expect_config_error({ mappings = 1 }, 'mappings', 'table') + expect_config_error({ mappings = { expand = 1 } }, 'mappings.expand', 'string') + expect_config_error({ mappings = { jump_next = 1 } }, 'mappings.jump_next', 'string') + expect_config_error({ mappings = { jump_prev = 1 } }, 'mappings.jump_prev', 'string') + expect_config_error({ mappings = { stop = 1 } }, 'mappings.stop', 'string') + expect_config_error({ expand = 1 }, 'expand', 'table') + expect_config_error({ expand = { prepare = 1 } }, 'expand.prepare', 'function') + expect_config_error({ expand = { match = 1 } }, 'expand.match', 'function') + expect_config_error({ expand = { select = 1 } }, 'expand.select', 'function') + expect_config_error({ expand = { insert = 1 } }, 'expand.insert', 'function') +end + +T['setup()']['ensures colors'] = function() + child.cmd('colorscheme default') + expect.match(child.cmd_capture('hi MiniSnippetsCurrent'), 'gui=underdouble guisp=#') +end + +T['setup()']['adds "code-snippets" filetype detection'] = function() + eq(child.lua_get('vim.filetype.match({ filename = "aaa.code-snippets" })'), 'json') +end + +-- Test are high-level. Granular testing is done in tests for `default_*()`. +T['expand()'] = new_set({ + hooks = { + pre_case = function() + child.lua([[ + _G.context_log = {} + MiniSnippets.config.snippets = { + function(context) + table.insert(_G.context_log, context) + return { { prefix = 'ba', body = 'BA=$1 T0=$0' } } + end, + { { prefix = 'aa', body = 'AA=$1 T0=$0' } }, + { prefix = 'xx', body = 'XX=$1 T0=$0' }, + }]]) + + child.bo.filetype = 'myft' + end, + }, +}) + +local expand = forward_lua('MiniSnippets.expand') + +T['expand()']['works with defaults'] = function() + local validate = function() + -- Should expand snippet with 'ba' prefix, because `default_prepare` sorts + -- resolved snippets in prefix's alphabetical order. + mock_select(2) + expand() + eq(child.lua_get('_G.context_log'), { { buf_id = 2, lang = 'myft' } }) + eq(child.lua_get('_G.select_args.items_formatted'), { 'aa │ AA=$1 T0=$0', 'ba │ BA=$1 T0=$0' }) + validate_active_session() + validate_state('i', { 'BA= T0=' }, { 1, 3 }) + + child.lua('_G.context_log, _G.select_args = {}, nil') + end + + -- Insert mode + type_keys('i', 'a') + validate() + ensure_clean_state() + + -- Normal mode + type_keys('i', 'a', '') + validate() +end + +T['expand()']['implements proper order of steps'] = function() + type_keys('i', 'a') + mock_select(2) + child.lua([[ + _G.steps_log = {} + local wrap_with_log = function(step_name, f) + return function(...) + table.insert(_G.steps_log, { step = step_name, args = { ... } }) + return f(...) + end + end + local opts = { + prepare = wrap_with_log('prepare', MiniSnippets.default_prepare), + match = wrap_with_log('match', MiniSnippets.default_match), + select = wrap_with_log('select', MiniSnippets.default_select), + insert = wrap_with_log('insert', MiniSnippets.default_insert), + } + MiniSnippets.expand(opts) + ]]) + + local ref_region = { from = { col = 1, line = 1 }, to = { col = 1, line = 1 } } + local ref_steps_log = { + { + -- Should be called with raw config snippets + step = 'prepare', + args = { + { + 'function', + { { prefix = 'aa', body = 'AA=$1 T0=$0' } }, + { prefix = 'xx', body = 'XX=$1 T0=$0' }, + }, + }, + }, + { + -- Should be called with normalized snippet array + step = 'match', + args = { + { + { prefix = 'aa', body = 'AA=$1 T0=$0', desc = 'AA=$1 T0=$0' }, + { prefix = 'ba', body = 'BA=$1 T0=$0', desc = 'BA=$1 T0=$0' }, + { prefix = 'xx', body = 'XX=$1 T0=$0', desc = 'XX=$1 T0=$0' }, + }, + }, + }, + { + -- Should be called with matched snippet array and `insert` function + step = 'select', + args = { + { + { prefix = 'aa', body = 'AA=$1 T0=$0', desc = 'AA=$1 T0=$0', region = ref_region }, + { prefix = 'ba', body = 'BA=$1 T0=$0', desc = 'BA=$1 T0=$0', region = ref_region }, + }, + 'function', + }, + }, + { + -- Should be called with selected snippet, `region` should be removed + step = 'insert', + args = { { prefix = 'ba', body = 'BA=$1 T0=$0', desc = 'BA=$1 T0=$0' } }, + }, + } + eq(child.lua_get('_G.sanitize_object(_G.steps_log)'), ref_steps_log) +end + +T['expand()']['uses config as default for steps'] = function() + child.lua([[ + _G.log = {} + MiniSnippets.config.expand = { + prepare = function(...) + table.insert(_G.log, 'global prepare') + return MiniSnippets.default_prepare(...) + end, + match = function(...) + table.insert(_G.log, 'global match') + return MiniSnippets.default_match(...) + end, + } + + vim.b.minisnippets_config = { expand = { + match = function(...) + table.insert(_G.log, 'buffer-local match') + return MiniSnippets.default_match(...) + end, + }} + ]]) + + mock_select(2) + expand() + -- Should prefer buffer-local over global config + eq(child.lua_get('_G.log'), { 'global prepare', 'buffer-local match' }) +end + +T['expand()']['prepares for `insert` to be executed at cursor in Insert mode'] = function() + child.lua([[ + _G.log = {} + MiniSnippets.config.expand.insert = function(...) + local state = { + mode = vim.fn.mode(), + line = vim.api.nvim_get_current_line(), + cursor = vim.api.nvim_win_get_cursor(0), + } + table.insert(_G.log, state) + end]]) + + local validate = function(keys, ref_line, ref_cursor) + type_keys(1, 'i', keys) + mock_select(1) + expand() + -- Should ensure Insert mode, remove matched region, ensure cursor + eq(child.lua_get('_G.log'), { { mode = 'i', line = ref_line, cursor = ref_cursor } }) + child.lua('_G.log = {}') + ensure_clean_state() + end + + -- Insert mode (in different line positions) + -- - Should remove matched region + validate({ 'a line start', '', '0', 'a' }, ' line start', { 1, 0 }) + validate({ 'line a middle', '', 'b', 'i' }, 'line middle', { 1, 5 }) + validate({ 'line end a' }, 'line end ', { 1, 9 }) + + -- - Empty base for matching (no region to remove) + validate({ 'line start', '', '0', 'i' }, 'line start', { 1, 0 }) + validate({ 'line middle', '', 'b', 'i' }, 'line middle', { 1, 5 }) + validate({ 'line end ' }, 'line end ', { 1, 9 }) + + -- Normal mode + validate({ 'a line start', '', '0' }, ' line start', { 1, 0 }) + validate({ 'line a middle', '', 'b' }, 'line middle', { 1, 5 }) + validate({ 'line end a', '' }, 'line end ', { 1, 9 }) + + validate({ ' line start', '', '0' }, ' line start', { 1, 0 }) + validate({ 'line middle', '', 'b', '' }, 'line middle', { 1, 4 }) + validate({ 'line end ', '' }, 'line end ', { 1, 8 }) +end + +T['expand()']['works with `vim.ui.select` which does not restore Insert mode'] = function() + child.lua([[ + vim.ui.select = function(items, opts, on_choice) + vim.api.nvim_feedkeys('\27', 'nx', false) + vim.schedule(function() on_choice(items[1]) end) + end + _G.log = {} + MiniSnippets.config.expand.insert = function(...) + local t = { mode = vim.fn.mode(), line = vim.api.nvim_get_current_line(), cursor = vim.api.nvim_win_get_cursor(0)} + table.insert(_G.log, t) + end + ]]) + + local validate = function(keys, ref_line, ref_cursor) + type_keys('i', keys) + expand() + -- Poke eventloop because both ensuring Insert mode from Normal mode and + -- jumping do not happen immediately + child.poke_eventloop() + eq(child.lua_get('_G.log'), { { mode = 'i', line = ref_line, cursor = ref_cursor } }) + ensure_clean_state() + child.lua('_G.log = {}') + end + + -- With removing region + validate({ 'uu a' }, 'uu ', { 1, 3 }) + validate({ 'uu a vv', '' }, 'uu vv', { 1, 3 }) + validate({ 'a vv', '' }, ' vv', { 1, 0 }) + + -- Without removing region. Currently doesn't work as ensuring Insert mode + -- moves cursor one cell to the left (as after `i`). It works for case + -- with removing region because there is info about where to put cursor. + -- validate({ 'uu ' }, 'uu ', { 1, 3 }) + -- validate({ 'u u', '' }, 'u u', { 1, 2 }) + -- validate({ ' u', '' }, ' u', { 1, 1 }) +end + +T['expand()']['accepts `false` for some steps'] = function() + -- Use all snippets if `match = false` + type_keys('i', 'a') + -- - Select snippet that is clearly not matched + mock_select(3) + expand({ match = false }) + validate_active_session() + -- - No region is removed because there was no match + validate_state('i', { 'aXX= T0=' }, { 1, 4 }) + ensure_clean_state() + + -- Force best (first) match insert with `select = false` + type_keys('i', 'a') + expand({ select = false }) + validate_active_session() + validate_state('i', { 'AA= T0=' }, { 1, 3 }) + ensure_clean_state() + + -- Return snippets with `insert = false` + type_keys('i', 'a') + + -- - Matched snippets by default + local ref_region = { from = { col = 1, line = 1 }, to = { col = 1, line = 1 } } + local ref_matched_snippets = { + { prefix = 'aa', body = 'AA=$1 T0=$0', desc = 'AA=$1 T0=$0', region = ref_region }, + { prefix = 'ba', body = 'BA=$1 T0=$0', desc = 'BA=$1 T0=$0', region = ref_region }, + } + eq(expand({ insert = false }), ref_matched_snippets) + validate_no_active_session() + validate_state('i', { 'a' }, { 1, 1 }) + + -- - All snippets if `match = false` + local ref_all_snippets = { + { prefix = 'aa', body = 'AA=$1 T0=$0', desc = 'AA=$1 T0=$0' }, + { prefix = 'ba', body = 'BA=$1 T0=$0', desc = 'BA=$1 T0=$0' }, + { prefix = 'xx', body = 'XX=$1 T0=$0', desc = 'XX=$1 T0=$0' }, + } + eq(expand({ match = false, insert = false }), ref_all_snippets) + validate_no_active_session() + validate_state('i', { 'a' }, { 1, 1 }) +end + +T['expand()']['does not warn about no matches if `insert = false`'] = function() + -- No matches + type_keys('i', 't') + eq(expand({ insert = false }), {}) + + -- No snippets at all + child.lua('MiniSnippets.config.snippets = {}') + eq(expand({ match = false, insert = false }), {}) + + -- In both cases output should be done silently + eq(child.lua_get('_G.notify_log'), {}) +end + +T['expand()']['validates correct step output'] = function() + local validate_bad_out = function(step_name, bad_output) + child.lua('_G.step_name, _G.bad_output = ' .. vim.inspect(step_name) .. ', ' .. vim.inspect(bad_output)) + child.lua('_G.bad_step = function() return _G.bad_output end') + local err_pattern = '`' .. step_name .. '`.*array of snippets' + expect.error(function() child.lua('MiniSnippets.expand({ [_G.step_name] = _G.bad_step })') end, err_pattern) + end + + -- Should error about not proper `prepare` output + validate_bad_out('prepare', 1) + validate_bad_out('prepare', { 1 }) + validate_bad_out('prepare', { 1 }) + validate_bad_out('prepare', { { body = 1 } }) + validate_bad_out('prepare', { { body = 'T1=$1', prefix = 1 } }) + validate_bad_out('prepare', { { body = 'T1=$1', desc = 1 } }) + validate_bad_out('prepare', { { body = 'T1=$1', region = 1 } }) + + -- Should error about not proper `match` output + validate_bad_out('match', 1) + validate_bad_out('match', { 1 }) + validate_bad_out('match', { 1 }) + validate_bad_out('match', { { body = 1 } }) + validate_bad_out('match', { { body = 'T1=$1', prefix = 1 } }) + validate_bad_out('match', { { body = 'T1=$1', desc = 1 } }) + validate_bad_out('match', { { body = 'T1=$1', region = 1 } }) + + -- Should warn about no matches and use `context` returned from `prepare` step + child.lua([[ + MiniSnippets.config.expand.prepare = function(...) + return _G.prepare_res or MiniSnippets.default_prepare(...), { data = 'my context' } + end + ]]) + + -- - Should warn about no matches + type_keys('i', 't') + expand() + validate_state('i', { 't' }, { 1, 1 }) + local ref_log = { { '(mini.snippets) No matches in context:\n{\n data = "my context"\n}', 'WARN' } } + eq(child.lua_get('_G.notify_log'), ref_log) + child.lua('_G.notify_log = {}') + ensure_clean_state() + + -- Should warn about no snippets (as returned by prepare step) at all + child.lua('_G.prepare_res = {}') + type_keys('i', 'a') + expand() + validate_state('i', { 'a' }, { 1, 1 }) + ref_log = { { '(mini.snippets) No snippets in context:\n{\n data = "my context"\n}', 'WARN' } } + eq(child.lua_get('_G.notify_log'), ref_log) + child.lua('_G.notify_log = {}') + ensure_clean_state() +end + +T['expand()']['validates input'] = function() + expect.error(function() expand({ prepare = 1 }) end, '`opts%.prepare`.*callable') + expect.error(function() expand({ match = 1 }) end, '`opts%.match`.*`false` or callable') + expect.error(function() expand({ select = 1 }) end, '`opts%.select`.*`false` or callable') + expect.error(function() expand({ insert = 1 }) end, '`opts%.insert`.*`false` or callable') +end + +T['expand()']['respects `vim.b.minisnippets_config`'] = function() + -- Should process buffer-local config after global config and in this case + -- remove snippets with these prefixes (as body is `nil`) + child.b.minisnippets_config = { snippets = { { prefix = 'aa' }, { prefix = 'ba' } } } + eq(expand({ insert = false, match = false }), { { prefix = 'xx', body = 'XX=$1 T0=$0', desc = 'XX=$1 T0=$0' } }) +end + +T['expand()']['respects `vim.{g,b}.minidiff_disable`'] = new_set({ + parametrize = { { 'g' }, { 'b' } }, +}, { + test = function(var_type) + child[var_type].minisnippets_disable = true + mock_select(2) + expand() + validate_no_active_session() + validate_state('n', { '' }, { 1, 0 }) + + child[var_type].minisnippets_disable = false + mock_select(2) + expand() + validate_active_session() + validate_state('i', { 'BA= T0=' }, { 1, 3 }) + end, +}) + +T['gen_loader'] = new_set({ + hooks = { + pre_case = function() + -- Monkey-patch `read_file()` to test caching + child.lua([[ + local read_file_orig = MiniSnippets.read_file + _G.read_args_log = {} + MiniSnippets.read_file = function(...) + table.insert(_G.read_args_log, { ... }) + return read_file_orig(...) + end + ]]) + end, + }, +}) + +T['gen_loader']['from_lang()'] = new_set() + +T['gen_loader']['from_lang()']['works'] = function() + child.o.runtimepath = test_dir_absolute .. '/subdir,' .. test_dir_absolute + child.lua('_G.loader = MiniSnippets.gen_loader.from_lang()') + local ref_snippet_data = { + -- Should first read runtime files (however nested) from "lua" directory + { + { { prefix = 'f', body = 'F=$1', desc = 'subdir/snippets/lua/deeper/another.json' } }, + { { prefix = 'e', body = 'E=$1', desc = 'subdir/snippets/lua/file.json' } }, + }, + { + { { prefix = 'd', body = 'D=$1', desc = 'subdir/snippets/lua/snips.lua' } }, + }, + -- And only then from exactly named files (however nested) + { + -- Should read in order of 'runtimepath' + { { prefix = 'c', body = 'C=$1', desc = 'subdir/snippets/lua.json' } }, + { { prefix = 'a', body = 'A=$1', desc = 'snippets/lua.json' } }, + { { prefix = 'g', body = 'G=$1', desc = 'snippets/nested/lua.json' } }, + }, + { + { { prefix = 'b', body = 'B=$1', desc = 'snippets/lua.lua' } }, + { { prefix = 'h', body = 'H=$1', desc = 'snippets/nested/lua.lua' } }, + }, + } + eq(child.lua_get('_G.loader({ lang = "lua" })'), ref_snippet_data) + + -- Should cache output per lang context and thus not call `read_file` again + local read_args_log = child.lua_get('_G.read_args_log') + child.lua('_G.loader({ lang = "lua" })') + eq(child.lua_get('_G.read_args_log'), read_args_log) +end + +T['gen_loader']['from_lang()']['respects `opts.lang_patterns`'] = function() + child.o.runtimepath = test_dir_absolute + child.lua('_G.loader = MiniSnippets.gen_loader.from_lang({ lang_patterns = { lua = { "lua.lua" } } })') + local ref_snippet_data = { { { { prefix = 'b', body = 'B=$1', desc = 'snippets/lua.lua' } } } } + eq(child.lua_get('_G.loader({ lang = "lua" })'), ref_snippet_data) +end + +T['gen_loader']['from_lang()']['works with not typical `lang` context'] = function() + child.o.runtimepath = test_dir_absolute + child.lua([[_G.loader = MiniSnippets.gen_loader.from_lang() ]]) + + -- Not string should be silently ignored + eq(child.lua_get('_G.loader({ lang = 1 })'), {}) + eq(child.lua_get('_G.loader({ lang = nil })'), {}) + eq(child.lua_get('_G.notify_log'), {}) + + -- Empty string + eq(child.lua_get('_G.loader({ lang = "" })'), {}) + + -- - Can be made working by explicitly adding language pattern + child.lua([[ + local lang_patterns = { [''] = { 'lua.json' } } + _G.loader_2 = MiniSnippets.gen_loader.from_lang({ lang_patterns = lang_patterns }) + ]]) + eq( + child.lua_get('_G.loader_2({ lang = "" })'), + { { { { prefix = 'a', body = 'A=$1', desc = 'snippets/lua.json' } } } } + ) +end + +T['gen_loader']['from_lang()']['outputs share cache per pattern'] = function() + child.o.runtimepath = test_dir_absolute .. '/subdir,' .. test_dir_absolute + child.lua([[ + local opts_1 = { lang_patterns = { lua = { 'lua.json', 'lua.lua' } } } + _G.loader_1 = MiniSnippets.gen_loader.from_lang(opts_1) + local opts_2 = { lang_patterns = { lua = { 'lua.json', 'lua.code-snippets' } } } + _G.loader_2 = MiniSnippets.gen_loader.from_lang(opts_2) + ]]) + + child.lua_get('_G.loader_1({ lang = "lua" })') + local read_args_log = child.lua_get('_G.read_args_log') + child.lua_get('_G.loader_2({ lang = "lua" })') + -- It should have read one extra 'subdir/snippets/lua.code-snippets', while + -- using cache for all 'lua.json' files + eq(#child.lua_get('_G.read_args_log'), #read_args_log + 1) +end + +T['gen_loader']['from_lang()']['respects `opts.cache`'] = function() + child.o.runtimepath = test_dir_absolute + child.lua('_G.loader = MiniSnippets.gen_loader.from_lang({ cache = false })') + + child.lua('_G.loader({ lang = "lua" })') + local read_args_log = child.lua_get('_G.read_args_log') + eq(#read_args_log > 0, true) + child.lua('_G.loader({ lang = "lua" })') + eq(#child.lua_get('_G.read_args_log') > #read_args_log, true) +end + +T['gen_loader']['from_lang()']['clears cache after `setup()`'] = function() + child.o.runtimepath = test_dir_absolute + child.lua('_G.loader = MiniSnippets.gen_loader.from_lang()') + + child.lua('_G.loader({ lang = "lua" })') + local read_args_log = child.lua_get('_G.read_args_log') + child.lua('MiniSnippets.setup()') + child.lua('_G.loader({ lang = "lua" })') + eq(#child.lua_get('_G.read_args_log') > #read_args_log, true) +end + +T['gen_loader']['from_lang()']['forwards `opts.cache` and `opts.silent` to `from_runtime()`'] = function() + child.lua([[ + local from_runtime_orig = MiniSnippets.gen_loader.from_runtime + _G.from_runtime_args_log = {} + MiniSnippets.gen_loader.from_runtime = function(...) + table.insert(_G.from_runtime_args_log, { ... }) + return from_runtime_orig(...) + end + ]]) + + child.o.runtimepath = test_dir_absolute + child.lua([[_G.loader = MiniSnippets.gen_loader.from_lang({ cache = false, silent = true })]]) + child.lua('_G.loader({ lang = "lua" })') + local from_runtime_args_log = child.lua_get('_G.from_runtime_args_log') + eq(from_runtime_args_log[1][2], { cache = false, silent = true }) + eq(from_runtime_args_log[2][2], { cache = false, silent = true }) + + -- Should not reuse generate `from_runtime()` loaders + child.lua('_G.loader({ lang = "lua" })') + eq(child.lua_get('_G.from_runtime_args_log'), from_runtime_args_log) +end + +T['gen_loader']['from_lang()']['validates input'] = function() + local validate_lang_patterns_error = function(lang_patterns, err_pattern) + child.lua('_G.lang_patterns = ' .. vim.inspect(lang_patterns)) + local lua_cmd = 'MiniSnippets.gen_loader.from_lang({ lang_patterns = _G.lang_patterns })' + expect.error(function() child.lua(lua_cmd) end, err_pattern) + end + + validate_lang_patterns_error({ 'lua' }, 'Keys of `opts.lang_patterns`.*string') + validate_lang_patterns_error({ lua = 'lua.lua' }, 'Values of `opts.lang_patterns`.*arrays') + validate_lang_patterns_error({ lua = { 1 } }, 'Values of `opts.lang_patterns`.*string') +end + +T['gen_loader']['from_runtime()'] = new_set() + +T['gen_loader']['from_runtime()']['works'] = function() + child.o.runtimepath = test_dir_absolute .. '/subdir' + child.lua([[_G.loader = MiniSnippets.gen_loader.from_runtime('lua/**/*.json')]]) + local ref_snippets = { + { { prefix = 'f', body = 'F=$1', desc = 'subdir/snippets/lua/deeper/another.json' } }, + { { prefix = 'e', body = 'E=$1', desc = 'subdir/snippets/lua/file.json' } }, + } + eq(child.lua_get('_G.loader()'), ref_snippets) + local read_args_log = child.lua_get('_G.read_args_log') + + -- Should cache output per pattern and thus not call `read_file` again + eq(child.lua_get('_G.loader()'), ref_snippets) + eq(child.lua_get('_G.read_args_log'), read_args_log) + + child.lua([[_G.loader_2 = MiniSnippets.gen_loader.from_runtime('lua/**/snips.lua')]]) + eq(child.lua_get('_G.loader_2()'), { { { prefix = 'd', body = 'D=$1', desc = 'subdir/snippets/lua/snips.lua' } } }) + eq(#child.lua_get('_G.read_args_log') > #read_args_log, true) + + -- Should read all matching files (not just first) + child.o.runtimepath = (test_dir_absolute .. '/subdir') .. ',' .. test_dir_absolute + child.lua([[_G.loader_all = MiniSnippets.gen_loader.from_runtime('lua.json')]]) + local ref_snippets_all = { + { { prefix = 'c', body = 'C=$1', desc = 'subdir/snippets/lua.json' } }, + { { prefix = 'a', body = 'A=$1', desc = 'snippets/lua.json' } }, + } + eq(child.lua_get('_G.loader_all()'), ref_snippets_all) +end + +T['gen_loader']['from_runtime()']['outputs share cache'] = function() + child.o.runtimepath = test_dir_absolute + child.lua([[ + _G.loader_1 = MiniSnippets.gen_loader.from_runtime('lua.json') + _G.loader_2 = MiniSnippets.gen_loader.from_runtime('lua.json') + ]]) + + child.lua_get('_G.loader_1()') + local read_args_log = child.lua_get('_G.read_args_log') + eq(#read_args_log > 0, true) + child.lua_get('_G.loader_2()') + eq(child.lua_get('_G.read_args_log'), read_args_log) +end + +T['gen_loader']['from_runtime()']['respects `opts.cache`'] = function() + child.o.runtimepath = test_dir_absolute + child.lua([[_G.loader = MiniSnippets.gen_loader.from_runtime('lua.json', { cache = false })]]) + + child.lua('_G.loader()') + local read_args_log = child.lua_get('_G.read_args_log') + -- Should use `read_file()` again as no caching is done + child.lua('_G.loader()') + eq(#child.lua_get('_G.read_args_log') > #read_args_log, true) +end + +T['gen_loader']['from_runtime()']['forwards `opts.cache` and `opts.silent` to `read_file()`'] = function() + child.o.runtimepath = test_dir_absolute + child.lua([[_G.loader = MiniSnippets.gen_loader.from_runtime('lua.json', { cache = false, silent = true })]]) + child.lua('_G.loader()') + local read_args_log = child.lua_get('_G.read_args_log') + eq(read_args_log[1][2], { cache = false, silent = true }) +end + +T['gen_loader']['from_runtime()']['respects `opts.all`'] = function() + child.o.runtimepath = (test_dir_absolute .. '/subdir') .. ',' .. test_dir_absolute + child.lua([[_G.loader_first = MiniSnippets.gen_loader.from_runtime('lua.json', { all = false })]]) + local ref_snippets_first = { { { prefix = 'c', body = 'C=$1', desc = 'subdir/snippets/lua.json' } } } + eq(child.lua_get('_G.loader_first()'), ref_snippets_first) +end + +T['gen_loader']['from_runtime()']['clears cache after `setup()`'] = function() + child.o.runtimepath = test_dir_absolute + child.lua([[_G.loader = MiniSnippets.gen_loader.from_runtime('lua.lua')]]) + + child.lua('_G.loader()') + local read_args_log = child.lua_get('_G.read_args_log') + child.lua('MiniSnippets.setup()') + child.lua('_G.loader()') + eq(#child.lua_get('_G.read_args_log') > #read_args_log, true) +end + +T['gen_loader']['from_runtime()']['validates input'] = function() + expect.error(function() child.lua('MiniSnippets.gen_loader.from_runtime(1)') end, '`pattern`.*string') +end + +T['gen_loader']['from_file()'] = new_set() + +T['gen_loader']['from_file()']['works'] = function() + -- Should be able to work with relative paths + child.lua('_G.loader = MiniSnippets.gen_loader.from_file("file-array.lua")') + + -- Should silently return `{}` if file is absent + eq(child.lua_get('_G.loader()'), {}) + eq(child.lua_get('_G.notify_log'), {}) + + -- Should load file if present and show warnings + child.fn.chdir(test_dir_absolute) + local out = child.lua_get('_G.loader()') + eq(#out, 5) + eq(out[1], { prefix = 'lua_a', body = 'LUA_A=$1', desc = 'Desc LUA_A' }) + expect.match(child.lua_get('_G.notify_log')[1][1], 'There were problems') + + -- Should work with paths stargin with "~" + local path_tilde = child.lua([[ + local path_tilde = vim.fn.fnamemodify('file-array.lua', ':p:~') + _G.loader_tilde = MiniSnippets.gen_loader.from_file(path_tilde) + return path_tilde + ]]) + if path_tilde:sub(1, 1) ~= '~' then return end + eq(child.lua_get('_G.loader_tilde()'), out) +end + +T['gen_loader']['from_file()']['does not cache if there were reading problems'] = function() + local temp_file = child.lua([[ + local temp_file = vim.fn.tempname() .. '.lua' + _G.loader = MiniSnippets.gen_loader.from_file(temp_file) + return temp_file + ]]) + MiniTest.finally(function() child.fn.delete(temp_file) end) + + child.fn.writefile({ 'return 1' }, temp_file) + eq(child.lua_get('_G.loader()'), {}) + + child.fn.writefile({ 'return { { prefix = "a", body = "A=$1" } }' }, temp_file) + eq(child.lua_get('_G.loader()'), { { prefix = 'a', body = 'A=$1' } }) +end + +T['gen_loader']['from_file()']['forwards `opts` to `read_file()`'] = function() + child.fn.chdir(test_dir_absolute) + child.lua([[ + local loader = MiniSnippets.gen_loader.from_file('file-array.lua', { cache = false, silent = true }) + loader() + ]]) + + local full_path = child.fn.fnamemodify('file-array.lua', ':p') + eq(child.lua_get('_G.read_args_log'), { { full_path, { cache = false, silent = true } } }) +end + +T['gen_loader']['from_file()']['clears cache after `setup()`'] = function() + child.fn.chdir(test_dir_absolute) + child.lua([[_G.loader = MiniSnippets.gen_loader.from_file('file-array.lua')]]) + + child.lua('_G.loader()') + local read_args_log = child.lua_get('_G.read_args_log') + child.lua('MiniSnippets.setup()') + child.lua('_G.loader()') + eq(#child.lua_get('_G.read_args_log') > #read_args_log, true) +end + +T['gen_loader']['from_file()']['validates input'] = function() + expect.error(function() child.lua('MiniSnippets.gen_loader.from_file(1)') end, '`path`.*string') +end + +T['read_file()'] = new_set() + +local read_file = forward_lua('MiniSnippets.read_file') + +local validate_problems = function(path_pattern, problem_pattern, clean) + local log = child.lua_get('_G.notify_log') + eq(#log, 1) + local pattern = '%(mini%.snippets%) There were problems reading file.*' + .. path_pattern + .. '.*:\n.*' + .. problem_pattern + expect.match(log[1][1], pattern) + expect.match(log[1][2], 'WARN') + if clean == nil or clean then child.lua('_G.notify_log = {}') end +end + +T['read_file()']['works with dict-like content'] = function() + local validate = function(filename) + local ref = { + { prefix = 'lua_a', body = 'LUA_A=$1', desc = 'Desc LUA_A' }, + { prefix = 'lua_b', body = 'LUA_B=$1', description = 'Desc LUA_B' }, + -- Should try to use table fields as description + { prefix = nil, body = 'LUA_C=$1', desc = 'name_c' }, + -- Should still return non-unique prefixes + { prefix = 'd', body = 'D1=$1', desc = 'dupl1' }, + { prefix = 'd', body = nil, desc = 'Dupl2' }, + } + local out = read_file(test_dir_absolute .. '/' .. filename) + eq(type(out), 'table') + + -- - Order is not guaranteed (but usually it is alphabetical by fields) + local compare = function(a, b) return (a.desc or a.description or '') < (b.desc or b.description or '') end + table.sort(out, compare) + table.sort(ref, compare) + eq(out, ref) + + -- - Order of problems is also not guaranteed + validate_problems(vim.pesc(filename), 'not a valid snippet data.*prefix = 1', false) + validate_problems(vim.pesc(filename), 'not a valid snippet data.*2') + end + + validate('file-dict.lua') + validate('file-dict.json') + validate('file-dict.code-snippets') +end + +T['read_file()']['works with array-like content'] = function() + local validate = function(filename) + local ref = { + { prefix = 'lua_a', body = 'LUA_A=$1', desc = 'Desc LUA_A' }, + -- Should not infer desc-like fields as there is no dict name to infer from + { prefix = 'lua_b', body = 'LUA_B=$1', description = 'Desc LUA_B' }, + { prefix = nil, body = 'LUA_C=$1' }, + -- Should still return non-unique prefixes + { prefix = 'd', body = 'D1=$1' }, + { prefix = 'd', body = nil, desc = 'Dupl2' }, + } + -- Order of valid entries should be preserved + eq(read_file(test_dir_absolute .. '/' .. filename), ref) + validate_problems(vim.pesc(filename), 'not a valid snippet data.*prefix = 1.*not a valid snippet data.*2') + end + + validate('file-array.lua') + validate('file-array.json') + validate('file-array.code-snippets') +end + +T['read_file()']['works with relative paths'] = function() + child.fn.chdir(test_dir_absolute) + eq(read_file('snippets/lua.json'), { { prefix = 'a', body = 'A=$1', desc = 'snippets/lua.json' } }) + + -- Should cache per full path + child.fn.chdir('subdir') + eq(read_file('snippets/lua.json'), { { prefix = 'c', body = 'C=$1', desc = 'subdir/snippets/lua.json' } }) +end + +T['read_file()']['works with paths starting with ~'] = function() + local path_tilde = child.fn.fnamemodify(test_dir_absolute .. '/snippets/lua.json', ':p:~') + if path_tilde:sub(1, 1) ~= '~' then return end + eq(read_file(path_tilde), { { prefix = 'a', body = 'A=$1', desc = 'snippets/lua.json' } }) +end + +T['read_file()']['correctly computes extension'] = function() + eq(read_file(test_dir_absolute .. '/file.many.dots.lua'), { { body = 'A=$1', prefix = 'a' } }) +end + +T['read_file()']['warns about problems during reading'] = function() + local validate = function(filename, problem_pattern) + -- Should return `nil` if there was a problem with reading + eq(read_file(test_dir_absolute .. '/' .. filename), vim.NIL) + validate_problems(vim.pesc(filename), problem_pattern) + end + + validate('not-present', 'File is absent or not readable') + validate('file.notsupported', 'Extension is not supported') + validate('bad-file-cant-execute.lua', 'Could not execute Lua file') + validate('bad-file-not-table-return.lua', 'Returned object is not a table') + validate('bad-file-cant-decode.json', 'valid JSON.*invalid token') + validate('bad-file-not-dict-object.json', 'not a dictionary or array') +end + +T['read_file()']['does not cache if there were reading problems'] = function() + local temp_file = child.fn.tempname() .. '.lua' + MiniTest.finally(function() child.fn.delete(temp_file) end) + + child.fn.writefile({ 'return 1' }, temp_file) + eq(read_file(temp_file), vim.NIL) + + child.fn.writefile({ 'return { { prefix = "a", body = "A=$1" } }' }, temp_file) + eq(read_file(temp_file), { { prefix = 'a', body = 'A=$1' } }) +end + +T['read_file()']['caches output'] = function() + child.lua([[ + local dofile_orig, vim_json_decode_orig = dofile, vim.json.decode + _G.n = 0 + _G.dofile = function(...) _G.n = _G.n + 1; return dofile_orig(...) end + vim.json.decode = function(...) _G.n = _G.n + 1; return vim_json_decode_orig(...) end + ]]) + + -- Use OS specific data for more robust testing + local test_dir_absolute_os = child.fn.fnamemodify(test_dir, ':p'):gsub('(.)[\\/]$', '%1') + local path_sep = helpers.is_windows() and '\\' or '/' + + local results = {} + local validate = function(filename, ref_n) + local out = read_file(test_dir_absolute_os .. path_sep .. filename) + eq(child.lua_get('_G.n'), ref_n) + if results[filename] ~= nil then eq(results[filename], out) end + results[filename] = out + end + + validate('file-dict.lua', 1) + validate('file-dict.lua', 1) + validate('file-dict.json', 2) + validate('file-dict.json', 2) + validate('file-dict.code-snippets', 3) + validate('file-dict.code-snippets', 3) + validate('file-array.lua', 4) + validate('file-array.lua', 4) + validate('file-array.json', 5) + validate('file-array.json', 5) + validate('file-array.code-snippets', 6) + validate('file-array.code-snippets', 6) + + -- Should use full path as cache id + child.fn.chdir(test_dir_absolute_os) + eq(read_file('file-array.lua'), results['file-array.lua']) + eq(child.lua_get('_G.n'), 6) + + -- Should return copy of cache entry + local res = child.lua([[ + local out = MiniSnippets.read_file("file-array.lua") + out[1].prefix = 'something else' + return MiniSnippets.read_file("file-array.lua")[1].prefix ~= 'something else' + ]]) + eq(res, true) +end + +T['read_file()']['respects `opts.cache`'] = function() + child.lua([[ + local dofile_orig, vim_json_decode_orig = dofile, vim.json.decode + _G.n = 0 + _G.dofile = function(...) _G.n = _G.n + 1; return dofile_orig(...) end + vim.json.decode = function(...) _G.n = _G.n + 1; return vim_json_decode_orig(...) end + ]]) + + local validate = function(filename, ref_n) + read_file(test_dir_absolute .. '/' .. filename, { cache = false }) + eq(child.lua_get('_G.n'), ref_n) + end + + validate('file-dict.lua', 1) + validate('file-dict.lua', 2) + validate('file-dict.json', 3) + validate('file-dict.json', 4) + validate('file-dict.code-snippets', 5) + validate('file-dict.code-snippets', 6) + validate('file-array.lua', 7) + validate('file-array.lua', 8) + validate('file-array.json', 9) + validate('file-array.json', 10) + validate('file-array.code-snippets', 11) + validate('file-array.code-snippets', 12) +end + +T['read_file()']['respects `opts.silent`'] = function() + -- Should not warn about any problems during reading + local read = function(filename) read_file(test_dir_absolute .. '/' .. filename, { silent = true }) end + read('file-array.lua') + read('not-present') + read('file.notsupported') + read('not-present') + read('file.notsupported') + read('bad-file-cant-execute.lua') + read('bad-file-not-table-return.lua') + read('bad-file-cant-decode.json') + read('bad-file-not-dict-object.json') + + eq(child.lua_get('_G.notify_log'), {}) +end + +T['read_file()']['validates input'] = function() + expect.error(function() read_file(1) end, '`path`.*string') +end + +T['default_prepare()'] = new_set({ + hooks = { + pre_case = function() + child.lua([[ + _G.loader_log = {} + _G.loader_1 = function(context) + table.insert(_G.loader_log, { 'loader_1', vim.deepcopy(context) }) + return { prefix = 'l1', body = 'L1=$1' } + end + _G.loader_2 = function(context) + table.insert(_G.loader_log, { 'loader_2', vim.deepcopy(context) }) + return { { prefix = 'l2_1', body = 'L2_1=$1' }, { prefix = 'l2_2', body = 'L2_2=$1' } } + end + ]]) + child.bo.filetype = 'myft' + end, + }, +}) + +local default_prepare = forward_lua('MiniSnippets.default_prepare') + +T['default_prepare()']['works'] = function() + local out = child.lua([[ + local raw_snippets = { + { prefix = 'a', body = 'A=$1' }, + { { prefix = 'aa', body = 'AA=$1' } }, + { { { prefix = 'bbb', body = 'BBB=$1' }, { prefix = 'cCc', body = 'CCC=$1' } } }, + { _G.loader_1 }, + _G.loader_2, + } + return MiniSnippets.default_prepare(raw_snippets) + ]]) + + -- Should be ordered by prefix + --stylua: ignore + local ref = { + { prefix = 'a', body = 'A=$1', desc = 'A=$1' }, + { prefix = 'aa', body = 'AA=$1', desc = 'AA=$1' }, + { prefix = 'bbb', body = 'BBB=$1', desc = 'BBB=$1' }, + { prefix = 'cCc', body = 'CCC=$1', desc = 'CCC=$1' }, + { prefix = 'l1', body = 'L1=$1', desc = 'L1=$1' }, + { prefix = 'l2_1', desc = 'L2_1=$1', body = 'L2_1=$1' }, + { prefix = 'l2_2', body = 'L2_2=$1', desc = 'L2_2=$1' }, + } + eq(out, ref) + + -- Should call each loader once + local cur_buf = get_buf() + local ref_loader_log = { + { 'loader_1', { buf_id = cur_buf, lang = 'myft' } }, + { 'loader_2', { buf_id = cur_buf, lang = 'myft' } }, + } + eq(child.lua_get('_G.loader_log'), ref_loader_log) +end + +T['default_prepare()']['works with tricky loaders'] = function() + local out = child.lua([[ + _G.loader_nested = function(context) + table.insert(_G.loader_log, { 'loader_nested', vim.deepcopy(context) }) + return { { _G.loader_1 }, _G.loader_2 } + end + return MiniSnippets.default_prepare({ _G.loader_nested }) + ]]) + --stylua: ignore + local ref = { + { prefix = 'l1', body = 'L1=$1', desc = 'L1=$1' }, + { prefix = 'l2_1', desc = 'L2_1=$1', body = 'L2_1=$1' }, + { prefix = 'l2_2', body = 'L2_2=$1', desc = 'L2_2=$1' }, + } + eq(out, ref) + + local cur_buf = get_buf() + local ref_loader_log = { + { 'loader_nested', { buf_id = cur_buf, lang = 'myft' } }, + { 'loader_1', { buf_id = cur_buf, lang = 'myft' } }, + { 'loader_2', { buf_id = cur_buf, lang = 'myft' } }, + } + eq(child.lua_get('_G.loader_log'), ref_loader_log) +end + +T['default_prepare()']['silently ignores bad entries'] = function() + local out = default_prepare({ {}, { prefix = 'a', body = 'a=$1' }, 1, { true } }) + eq(out, { { prefix = 'a', body = 'a=$1', desc = 'a=$1' } }) +end + +T['default_prepare()']['properly normalizes snippets'] = function() + -- Only unique non-empty prefixes should be present and resolved in order + -- they are traversed (latest wins in full, not by parts) + local out = child.lua_get([[ + MiniSnippets.default_prepare({ + { prefix = 'a', body = 'a1=$1', desc = 'Desc a1' }, + { prefix = 'b', body = 'b1=$1', desc = 'Desc b1' }, + function() return { prefix = 'a', body = 'a2=$1', desc = 'Desc a2' } end, + { { prefix = 'b', body = 'b2=$1' } }, + }) + ]]) + eq(out, { { prefix = 'a', body = 'a2=$1', desc = 'Desc a2' }, { prefix = 'b', body = 'b2=$1', desc = 'b2=$1' } }) + + -- Ensures prefix/body/desc strings: array prefix adds snippet for every + -- prefix, array body and desc get concatenated with "\n" + local raw_snippets = { + { prefix = { 'd', 'c' }, body = { 'multi', 'line' }, desc = { 'also', 'multi', 'line' } }, + { prefix = { 'a', 'b' }, body = { 'single line' }, description = { 'also single line' } }, + { prefix = 'x', body = { 'aaaaa', 'bbbb' } }, + } + eq(default_prepare(raw_snippets), { + { prefix = 'a', body = 'single line', desc = 'also single line' }, + { prefix = 'b', body = 'single line', desc = 'also single line' }, + { prefix = 'c', body = 'multi\nline', desc = 'also\nmulti\nline' }, + { prefix = 'd', body = 'multi\nline', desc = 'also\nmulti\nline' }, + { prefix = 'x', body = 'aaaaa\nbbbb', desc = 'aaaaa\nbbbb' }, + }) + + -- Absent prefix/body/desc: prefix should be inferred as empty and all added, + -- absent body should remove snippet with its prefix, absent desc should be + -- inferred as body. + raw_snippets = { + -- Absent prefix should be inferred as empty and every added + { prefix = nil, body = 'a2=$1', desc = 'Desc a2' }, + { prefix = nil, body = 'a3=$1', desc = 'Desc a3' }, + { prefix = nil, body = 'a1=$1', desc = 'Desc a1' }, + -- Absent body should remove snippet with its prefix + { prefix = 'b', body = 'b=$1', desc = 'Desc b' }, + { prefix = 'b', body = nil, desc = 'Desc no matter' }, + -- Absent desc should be inferred desc>description>body + { prefix = 'c1', body = 'c1=$1', description = 'Description' }, + { prefix = 'c2', body = 'c2=$1' }, + -- Absent prefix and body should not matter + { prefix = nil, body = nil, desc = 'No matter' }, + } + eq(default_prepare(raw_snippets), { + { prefix = '', body = 'a2=$1', desc = 'Desc a2' }, + { prefix = '', body = 'a3=$1', desc = 'Desc a3' }, + { prefix = '', body = 'a1=$1', desc = 'Desc a1' }, + -- No 'b' prefix, as it was removed + { prefix = 'c1', body = 'c1=$1', desc = 'Description' }, + { prefix = 'c2', body = 'c2=$1', desc = 'c2=$1' }, + }) +end + +T['default_prepare()']['uses proper default context'] = function() + local validate_context = function(ref_context) + local out = child.lua_get('select(2, MiniSnippets.default_prepare({}))') + eq(out, ref_context) + end + + local cur_buf = get_buf() + + -- By default should use buffer's filetype + child.bo.filetype = 'myft' + validate_context({ buf_id = cur_buf, lang = 'myft' }) + + -- With present tree-sitter should use local parser lanuage + if child.fn.has('nvim-0.10') == 0 then MiniTest.skip('Testing on Neovim>=0.10 is easier with built-in parsers') end + + child.bo.filetype = 'vim' + child.lua('vim.treesitter.start()') + set_lines({ + 'set background=dark', + 'lua << EOF', + 'print(1)', + 'vim.api.nvim_exec2([[', + ' set background=light', + ']])', + 'EOF', + }) + child.cmd('startinsert') + set_cursor(1, 0) + validate_context({ buf_id = cur_buf, lang = 'vim' }) + set_cursor(3, 0) + validate_context({ buf_id = cur_buf, lang = 'lua' }) + set_cursor(5, 0) + validate_context({ buf_id = cur_buf, lang = 'vim' }) +end + +T['default_prepare()']['respects `opts.context`'] = function() + local validate = function(context) + child.lua('_G.context = ' .. vim.inspect(context)) + child.lua([[ + MiniSnippets.default_prepare({ _G.loader_1, _G.loader_2 }, { context = _G.context }) + ]]) + eq(child.lua_get('_G.loader_log'), { { 'loader_1', context }, { 'loader_2', context } }) + child.lua('_G.loader_log = {}') + end + + validate({ buf_id = get_buf() }) + validate({}) + validate(1) + validate(true) +end + +T['default_prepare()']['validates input'] = function() + expect.error(function() default_prepare(1) end, '`raw_snippets`.*array') +end + +T['default_match()'] = new_set() + +local default_match = forward_lua('MiniSnippets.default_match') + +--stylua: ignore +T['default_match()']['works with exact match'] = function() + local snippets = { + { prefix = 'a', body = 'a1=$1' }, + { prefix = 'aa', body = 'A1=$1', desc = 'Ends as other prefix' }, + { prefix = '_t', body = '_1=$1' }, + { prefix = ' t', body = ' 1=$1' }, + { prefix = 't_', body = 'T1=$1' }, + { prefix = 't ', body = 't1=$1' }, + -- Should ignore empty and absent prefixes + { prefix = '', body = '$1', desc = 'Empty prefix' }, + { body = '$1$2', desc = 'No prefix' }, + } + + local validate = function(keys, snip_id, ref_region) + type_keys(keys) + local ref = vim.deepcopy(snippets[snip_id]) + if ref ~= nil then ref.region = ref_region end + eq(default_match(snippets), { ref }) + ensure_clean_state() + end + + validate({ 'i', 'a' }, 1, { from = { line = 1, col = 1 }, to = { line = 1, col = 1 } }) + + -- In different line positions + validate({ 'i', 'xx a x', '' }, 1, { from = { line = 1, col = 4 }, to = { line = 1, col = 4 } }) + validate({ 'i', 'a x', '' }, 1, { from = { line = 1, col = 1 }, to = { line = 1, col = 1 } }) + + -- Not in first line + validate({ 'i', 'xa' }, 1, { from = { line = 2, col = 1 }, to = { line = 2, col = 1 } }) + validate({ 'i', 'xax' }, 1, { from = { line = 2, col = 1 }, to = { line = 2, col = 1 } }) + + -- Should match the widest exact match + validate({ 'i', 'aa' }, 2, { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } }) + validate({ 'i', ' aa' }, 2, { from = { line = 1, col = 2 }, to = { line = 1, col = 3 } }) + + -- Should only use part to the left of cursor + validate({ 'i', 'aa', '' }, 1, { from = { line = 1, col = 1 }, to = { line = 1, col = 1 } }) + + -- Should ignore exact match if it is not after whitespace or punctuation + validate({ 'i', 'ba' }, nil) + validate({ 'i', 'baa' }, nil) + + -- Should match regardless of prefix (even if starts/ends with space/punct) + validate({ 'i', '_t' }, 3, { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } }) + validate({ 'i', ' t' }, 4, { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } }) + validate({ 'i', 't_' }, 5, { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } }) + validate({ 'i', 't ' }, 6, { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } }) + + validate({ 'i', ' _t' }, 3, { from = { line = 1, col = 2 }, to = { line = 1, col = 3 } }) + validate({ 'i', ' t' }, 4, { from = { line = 1, col = 2 }, to = { line = 1, col = 3 } }) + + -- Should work in Normal mode and include character under cursor + validate({'i', 'aa', '', '$'}, 2, { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } }) +end + +T['default_match()']['works with fuzzy match'] = function() + local snippets = { + { prefix = 'a_bc', body = 'a_bc=$1', desc = 'Should preserve' }, + { prefix = 'axbc', body = 'axbc=$1' }, + { prefix = 'xabc', body = 'xabc=$1' }, + -- Should ignore empty and absent prefixes + { prefix = '', body = '$1', desc = 'Empty prefix' }, + { body = '$1$2', desc = 'No prefix' }, + } + + local validate = function(keys, snip_ids, ref_region) + type_keys(keys) + local ref_arr = vim.tbl_map(function(id) + local res = vim.deepcopy(snippets[id]) + res.region = ref_region + return res + end, snip_ids) + eq(default_match(snippets), ref_arr) + ensure_clean_state() + end + + -- Should return from best to worst fuzzy matches + local ref_region = { from = { line = 1, col = 1 }, to = { line = 1, col = 1 } } + validate({ 'i', 'x' }, { 3, 2 }, ref_region) + + ref_region = { from = { line = 1, col = 1 }, to = { line = 1, col = 2 } } + validate({ 'i', 'xb' }, { 2, 3 }, ref_region) + + -- Should only use part to the left of cursor + ref_region = { from = { line = 1, col = 1 }, to = { line = 1, col = 1 } } + validate({ 'i', 'xb', '' }, { 3, 2 }, ref_region) + + -- Should compute base as widest non-whitespace characters + ref_region = { from = { line = 1, col = 2 }, to = { line = 1, col = 3 } } + validate({ 'i', ' xb' }, { 2, 3 }, ref_region) + validate({ 'i', '\txb' }, { 2, 3 }, ref_region) + + validate({ 'i', 'xxb' }, {}, nil) + validate({ 'i', 'b_' }, {}, nil) + + -- Should not return "connected" regions (to not be modifiable in place) + type_keys('i', 'ab') + local res = child.lua([[ + local snippets = { { prefix = 'axb', body = 'axb=$1' }, { prefix = 'axxb', body = 'axxb=$1' } } + local matches = MiniSnippets.default_match(snippets) + matches[1].region.from.line = matches[1].region.from.line + 1 + return matches[1].region.from.line ~= matches[2].region.from.line + ]]) + eq(res, true) + ensure_clean_state() +end + +T['default_match()']['works in special cases'] = function() + local snippets = { { prefix = 'ab', body = 'ab=$1' }, { prefix = 'axb', body = 'axb=$1' } } + + -- Should return all input snippets if no exact and empty base + type_keys('i') + eq(default_match(snippets), snippets) + type_keys(' ') + eq(default_match(snippets), snippets) + type_keys('\t') + eq(default_match(snippets), snippets) + + -- Should work with empty array + eq(default_match({}), {}) +end + +T['default_match()']['does not return fuzzy matches if there is exact match'] = function() + local snippets = { { prefix = 'ab', body = 'ab=$1' }, { prefix = 'axb', body = 'axb=$1' } } + type_keys('i', 'ab') + eq( + default_match(snippets), + { { prefix = 'ab', body = 'ab=$1', region = { from = { col = 1, line = 1 }, to = { col = 2, line = 1 } } } } + ) +end + +T['default_match()']['does not modify input snippets'] = function() + type_keys('i', 'ab') + local res_exact = child.lua([[ + local snippets = { { prefix = 'ab', body = 'ab=$1' } } + local matches = MiniSnippets.default_match(snippets) + return { matches_have_region = matches[1].region ~= nil, orig_no_region = snippets[1].region == nil } + ]]) + eq(res_exact, { matches_have_region = true, orig_no_region = true }) + ensure_clean_state() + + type_keys('i', 'ab') + local res_fuzzy = child.lua([[ + local snippets = { { prefix = 'axb', body = 'axb=$1' } } + local matches = MiniSnippets.default_match(snippets) + return { matches_have_region = matches[1].region ~= nil, orig_no_region = snippets[1].region == nil } + ]]) + eq(res_fuzzy, { matches_have_region = true, orig_no_region = true }) +end + +T['default_match()']['respects `opts.pattern_exact_boundary`'] = function() + local snippets = { { prefix = 'a', body = 'a=$1' }, { prefix = 'ab', body = 'ab=$1' } } + + type_keys('i', '_a') + -- - No matches as '_' is not whitespace and '_a' is used as fuzzy match base + eq(#default_match(snippets, { pattern_exact_boundary = '%s?' }), 0) + ensure_clean_state() + + -- Should match pattern against empty string at line start + type_keys('i', 'a') + -- - There are two matches because they both are fuzzy, i.e. no exact match + eq(#default_match(snippets, { pattern_exact_boundary = '%s' }), 2) +end + +T['default_match()']['respects `opts.pattern_fuzzy`'] = function() + local snippets = { { prefix = 'ab', body = 'ab=$1' }, { prefix = 'xx', body = 'xx=$1' } } + + type_keys('i', '_a') + eq(#default_match(snippets), 0) + eq(#default_match(snippets, { pattern_fuzzy = '%w*' }), 1) + ensure_clean_state() + + -- Fuzzy matching empty string should return all snippets + type_keys('i', 'a') + eq(#default_match(snippets), 1) + eq(#default_match(snippets, { pattern_fuzzy = '[^a]*' }), 2) + ensure_clean_state() + + -- Empty string can be used to not do fuzzy matching + type_keys('i', 'a') + eq(#default_match(snippets), 1) + eq(#default_match(snippets, { pattern_fuzzy = '' }), 0) +end + +T['default_match()']['validates input'] = function() + local validate = function(err_pattern, ...) + local args = { ... } + expect.error(function() default_match(unpack(args)) end, err_pattern) + end + validate('`snippets`.*array', 1) + validate('`snippets`.*snippets', { 1 }) + validate('`snippets`.*snippets', { { body = 1 } }) + validate('`snippets`.*snippets', { { body = 'T1=$1', prefix = 1 } }) + validate('`snippets`.*snippets', { { body = 'T1=$1', desc = 1 } }) + validate('`snippets`.*snippets', { { body = 'T1=$1', region = 1 } }) + + validate('`opts.pattern_exact_boundary`.*string', { { body = 'T1=$1' } }, { pattern_exact_boundary = 1 }) + validate('`opts.pattern_fuzzy`.*string', { { body = 'T1=$1' } }, { pattern_fuzzy = 1 }) +end + +T['default_select()'] = new_set() + +local default_select = forward_lua('MiniSnippets.default_select') + +T['default_select()']['works'] = function() + -- Should stop early for empty array of snippets + default_select({}) + eq(child.lua_get('_G.notify_log'), { { '(mini.snippets) No snippets to select from', 'WARN' } }) + + -- By default should insert a single snippet + default_select({ { body = 'T1=$1 T0=$0' } }) + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + validate_active_session() + ensure_clean_state() + + -- Should call `vim.ui.select` for more than one snippets + set_lines({ 'abc' }) + local region = { from = { line = 1, col = 1 }, to = { line = 1, col = 3 } } + mock_select(2) + local snippets = { + { prefix = 'T', body = 'T1=$1 T0=$0' }, + { body = 'U1=$1 U0=$0', desc = 'U snippet', region = region }, + { prefix = 'xxx', body = 'X1=$1 X0=$0', description = 'X snippet' }, + } + default_select(snippets) + validate_state('i', { 'U1= U0=' }, { 1, 3 }) + validate_active_session() + eq(child.lua_get('_G.select_args'), { + items = snippets, + items_formatted = { + 'T │ ', + ' │ U snippet', + 'xxx │ X snippet', + }, + prompt = 'Snippets', + }) +end + +T['default_select()']['respects multibyte characters during formatting'] = function() + mock_select(2) + default_select({ + { prefix = 'ыыы', body = 'Ы1=$1 Ы0=$0', desc = 'Ы snippet' }, + { prefix = 'uuu', body = 'U1=$1 U0=$0', desc = 'U snippet' }, + }) + eq(child.lua_get('_G.select_args.items_formatted'), { 'ыыы │ Ы snippet', 'uuu │ U snippet' }) +end + +T['default_select()']['respects `insert`'] = function() + mock_select(2) + child.lua([[ + _G.my_insert = function(...) _G.args = { ... } end + local snippets = { { body = 'T1=$1 T0=$0' }, { body = 'T1=$1 T0=$0' } } + MiniSnippets.default_select(snippets, my_insert) + ]]) + eq(child.lua_get('_G.args'), { { body = 'T1=$1 T0=$0' } }) +end + +T['default_select()']['respects `opts.insert_single`'] = function() + child.lua('vim.ui.select = function(items) _G.items = items end') + child.lua([[MiniSnippets.default_select({ { body = 'T1=$1 T0=$0' } }, nil, { insert_single = false })]]) + -- Should still call `vim.ui.select()` even with single item array input + eq(child.lua_get('_G.items'), { { body = 'T1=$1 T0=$0' } }) +end + +T['default_select()']['validates input'] = function() + expect.error(function() default_select(1) end, '`snippets`.*array') + expect.error(function() default_select({ 1 }) end, '`snippets`.*snippets') + expect.error(function() default_select({ { body = 1 } }) end, '`snippets`.*snippets') + expect.error(function() default_select({ { body = 'T1=$1 T0=$0', prefix = 1 } }) end, '`snippets`.*snippets') + expect.error(function() default_select({ { body = 'T1=$1 T0=$0', desc = 1 } }) end, '`snippets`.*snippets') + expect.error(function() default_select({ { body = 'T1=$1 T0=$0', region = 1 } }) end, '`snippets`.*snippets') + expect.error(function() default_select({ { body = 'T1=$1 T0=$0' } }, 1) end, '`insert`.*callable') +end + +T['default_insert()'] = new_set() + +local default_insert = forward_lua('MiniSnippets.default_insert') + +T['default_insert()']['works'] = function() + -- Just text + child.cmd('startinsert') + default_insert({ body = 'Text' }) + validate_state('i', { 'Text' }, { 1, 4 }) + validate_no_active_session() + ensure_clean_state() + + -- With tabstops (should start active session) + child.cmd('startinsert') + default_insert({ body = 'T1=$1 T2=$2' }) + validate_state('i', { 'T1= T2=' }, { 1, 3 }) + validate_active_session() + jump('next') + validate_state('i', { 'T1= T2=' }, { 1, 7 }) + ensure_clean_state() + + -- Should allow array of strings as body + child.cmd('startinsert') + default_insert({ body = { 'T1=$1', 'T0=$0' } }) + validate_state('i', { 'T1=', 'T0=' }, { 1, 3 }) +end + +T['default_insert()']['ensures Insert mode in current buffer'] = function() + -- Normal mode + default_insert({ body = 'Text' }) + validate_state('i', { 'Text' }, { 1, 4 }) + ensure_clean_state() + + default_insert({ body = 'T1=$1' }) + validate_state('i', { 'T1=' }, { 1, 3 }) + validate_active_session() + ensure_clean_state() + + -- Visual mode + type_keys('v') + eq(child.fn.mode(), 'v') + default_insert({ body = 'T1=$1 T2=$2' }) + validate_state('i', { 'T1= T2=' }, { 1, 3 }) + ensure_clean_state() + + -- Command-line mode + type_keys(':') + eq(child.fn.mode(), 'c') + default_insert({ body = 'T1=$1' }) + validate_state('i', { 'T1=' }, { 1, 3 }) +end + +T['default_insert()']['deletes snippet region'] = function() + local validate = function(mode, col_from, col_to, ref_line, ref_cursor) + if mode == 'i' then type_keys('i') end + set_lines({ 'abcd' }) + local region = { from = { line = 1, col = col_from }, to = { line = 1, col = col_to } } + default_insert({ body = 'T1=$1', region = region }) + validate_state('i', { ref_line }, ref_cursor) + + ensure_clean_state() + end + + validate('i', 1, 1, 'T1=bcd', { 1, 3 }) + validate('i', 1, 2, 'T1=cd', { 1, 3 }) + validate('i', 2, 2, 'aT1=cd', { 1, 4 }) + validate('i', 2, 3, 'aT1=d', { 1, 4 }) + validate('i', 3, 3, 'abT1=d', { 1, 5 }) + validate('i', 3, 4, 'abT1=', { 1, 5 }) + + validate('n', 1, 1, 'T1=bcd', { 1, 3 }) + validate('n', 1, 2, 'T1=cd', { 1, 3 }) + validate('n', 2, 2, 'aT1=cd', { 1, 4 }) + validate('n', 2, 3, 'aT1=d', { 1, 4 }) + validate('n', 3, 3, 'abT1=d', { 1, 5 }) + validate('n', 3, 4, 'abT1=', { 1, 5 }) +end + +T['default_insert()']['can be used to create nested session'] = function() + default_insert({ body = 'T1=$1' }) + validate_n_sessions(1) + validate_state('i', { 'T1=' }, { 1, 3 }) + + default_insert({ body = 'T2=$2' }) + validate_n_sessions(2) + validate_state('i', { 'T1=T2=' }, { 1, 6 }) +end + +T['default_insert()']['indent'] = new_set() + +T['default_insert()']['indent']['is added on every new line'] = function() + type_keys('i', ' \t') + default_insert({ body = 'multi\n line\n\ttext\n' }) + validate_state('i', { ' \tmulti', ' \t line', ' \t\ttext', ' \t' }, { 4, 2 }) + ensure_clean_state() + + type_keys('i', ' ') + default_insert({ body = 'T1=$1\nT0=$0' }) + validate_state('i', { ' T1=', ' T0=' }, { 1, 4 }) + ensure_clean_state() + + -- Should use line's indent (even if inserted not next to whitespace) + type_keys('i', ' \txxx \t') + default_insert({ body = 'multi\nline\n' }) + validate_state('i', { ' \txxx \tmulti', ' \tline', ' \t' }, { 3, 2 }) + ensure_clean_state() + + -- Inserting in Normal mode is the same as pressing `i` beforehand + type_keys('i', ' ', '') + default_insert({ body = 'multi\nline' }) + validate_state('i', { ' multi', ' line ' }, { 2, 6 }) +end + +--stylua: ignore +T['default_insert()']['indent']['works inside comments'] = function() + local validate = function(cur_line, lines_after) + set_lines({ cur_line }) + type_keys('A') + default_insert({ body = 'multi\nline\n text\n' }) + eq(get_lines(), lines_after) + ensure_clean_state() + end + + -- Indent with comment under 'commentstring' + child.o.commentstring = '# %s' + + validate('#', { '#multi', '#line', '# text', '#' }) + validate('# ', { '# multi', '# line', '# text', '# ' }) + validate('#\t', { '#\tmulti', '#\tline', '#\t text', '#\t' }) + validate(' # ', { ' # multi', ' # line', ' # text', ' # ' }) + validate('\t# ', { '\t# multi', '\t# line', '\t# text', '\t# ' }) + validate('\t#\t', { '\t#\tmulti', '\t#\tline', '\t#\t text', '\t#\t' }) + + validate('#xx', { '#xxmulti', '#line', '# text', '#' }) + validate(' # xx ', { ' # xx multi', ' # line', ' # text', ' # ' }) + validate('\t#\txx ', { '\t#\txx multi', '\t#\tline', '\t#\t text', '\t#\t' }) + + -- Indent with comment under 'comments' parts + child.bo.comments = ':---,:--' + + validate('--', { '--multi', '--line', '-- text', '--' }) + validate('-- ', { '-- multi', '-- line', '-- text', '-- ' }) + validate('--\t', { '--\tmulti', '--\tline', '--\t text', '--\t' }) + validate(' -- ', { ' -- multi', ' -- line', ' -- text', ' -- ' }) + validate('\t-- ', { '\t-- multi', '\t-- line', '\t-- text', '\t-- ' }) + validate('\t--\t', { '\t--\tmulti', '\t--\tline', '\t--\t text', '\t--\t' }) + + validate('--xx', { '--xxmulti', '--line', '-- text', '--' }) + validate(' -- xx', { ' -- xxmulti', ' -- line', ' -- text', ' -- ' }) + validate('\t--\txx', { '\t--\txxmulti', '\t--\tline', '\t--\t text', '\t--\t' }) + + -- Should respect `b` flag (leader should be followed by space/tab/EOL) + child.bo.comments = 'b:*' + validate('*', { '*multi', 'line', ' text', '' }) + validate(' *', { ' *multi', ' line', ' text', ' ' }) + validate('\t*', { '\t*multi', '\tline', '\t text', '\t' }) + + validate('* ', { '* multi', '* line', '* text', '* ' }) + validate('*\t', { '*\tmulti', '*\tline', '*\t text', '*\t' }) + validate(' * ', { ' * multi', ' * line', ' * text', ' * ' }) + validate('\t*\t', { '\t*\tmulti', '\t*\tline', '\t*\t text', '\t*\t' }) + + validate('* xx', { '* xxmulti', '* line', '* text', '* ' }) + validate('*\txx', { '*\txxmulti', '*\tline', '*\t text', '*\t' }) + + -- Should respect `f` flag (only first line should have it) + child.bo.comments = 'f:-' + validate('-', { '-multi', 'line', ' text', '' }) + validate(' -', { ' -multi', ' line', ' text', ' ' }) + validate('\t-', { '\t-multi', '\tline', '\t text', '\t' }) + + validate(' - ', { ' - multi', ' line', ' text', ' ' }) + validate('\t-\t', { '\t-\tmulti', '\tline', '\t text', '\t' }) +end + +T['default_insert()']['indent']['computes "indent at cursor"'] = function() + type_keys('i', ' ', '') + eq(get_cursor(), { 1, 2 }) + default_insert({ body = 'multi\nline' }) + validate_state('i', { ' multi', ' line ' }, { 2, 6 }) + ensure_clean_state() + + child.o.commentstring = '--%s' + type_keys('i', ' --', '') + eq(get_cursor(), { 1, 2 }) + default_insert({ body = 'multi\nline' }) + -- `--` is not treated as part of indent because cursor is inside of it + validate_state('i', { ' -multi', ' line-' }, { 2, 5 }) +end + +T['default_insert()']['indent']['respects manual lookup entries'] = function() + type_keys('i', ' \t') + local lookup = { ['1'] = 'tab\nstop', AAA = 'aaa\nbbb' } + default_insert({ body = 'T1=$1\nAAA=$AAA' }, { lookup = lookup }) + validate_state('i', { ' \tT1=tab', ' \tstop', ' \tAAA=aaa', ' \tbbb' }, { 2, 6 }) +end + +T['default_insert()']['triggers start/stop events'] = function() + local make_ref_data = function(snippet_body) + return { session = { insert_args = { snippet = { body = snippet_body } } } } + end + setup_event_log() + local body, cur_buf = 'T1=$1 T0=0', get_buf() + + default_insert({ body = body }) + eq_partial_tbl(get_au_log(), { { event = 'MiniSnippetsSessionStart', data = make_ref_data(body), buf_id = cur_buf } }) + clean_au_log() + + stop() + eq_partial_tbl(get_au_log(), { { event = 'MiniSnippetsSessionStop', data = make_ref_data(body), buf_id = cur_buf } }) +end + +T['default_insert()']['respects tab-related options'] = function() + child.bo.expandtab = true + child.bo.shiftwidth = 3 + default_insert({ body = '\tT1=$1\n\t\tT0=$0' }) + validate_state('i', { ' T1=', ' T0=' }, { 1, 6 }) + ensure_clean_state() + + child.bo.shiftwidth, child.bo.tabstop = 0, 2 + default_insert({ body = '\ttext\t\t' }) + validate_state('i', { ' text ' }, { 1, 10 }) + ensure_clean_state() + + child.bo.expandtab = false + default_insert({ body = '\tT1=$1\n\t\tT0=$0' }) + validate_state('i', { '\tT1=', '\t\tT0=' }, { 1, 4 }) + ensure_clean_state() + + default_insert({ body = '\ttext\t\t' }) + validate_state('i', { '\ttext\t\t' }, { 1, 7 }) +end + +T['default_insert()']['shows tabstop choices after start'] = function() + -- Called in Insert mode + type_keys('i') + default_insert({ body = 'T1=${1|aa,bb|}' }) + validate_pumitems({ 'aa', 'bb' }) + ensure_clean_state() + + -- Called in Normal mode + default_insert({ body = 'T1=${1|aa,bb|}' }) + validate_pumitems({ 'aa', 'bb' }) + -- - Should not have side effects + eq(child.cmd_capture('au ModeChanged'):find('Insert') == nil, true) +end + +T['default_insert()']['direct call removes placeholder'] = function() + default_insert({ body = 'T1=${1:}' }) + -- This can happen if inserting snippet without typing prefix to match) after + -- jumping to tabstop with placeholder + default_insert({ body = 'U1=$1' }) + validate_state('i', { 'T1=U1=' }, { 1, 6 }) +end + +T['default_insert()']['treats any digit sequence as unique tabstop'] = function() + default_insert({ body = '$1 $2 $01 $11 $02 $00 $9' }) + validate_active_session() + -- Should treat as separate tabstops and order as numbers and then as strings + local ref_tabstops_partial = { + ['00'] = { next = '01', prev = '0' }, + ['01'] = { next = '1', prev = '00' }, + ['1'] = { next = '02', prev = '01' }, + ['02'] = { next = '2', prev = '1' }, + ['2'] = { next = '9', prev = '02' }, + ['9'] = { next = '11', prev = '2' }, + ['11'] = { next = '0', prev = '9' }, + -- Exactly '0' is a final tabstop + ['0'] = { next = '00', prev = '11' }, + } + eq_partial_tbl(get().tabstops, ref_tabstops_partial) +end + +T['default_insert()']['can work with special variables'] = function() + -- Prepare linewise selected text which ends with "\n" and adds extra line + set_lines({ 'sel' }) + type_keys('dd') + + default_insert({ body = 'Selected=$TM_SELECTED_TEXT\n$TM_LINE_NUMBER\n$WORKSPACE_FOLDER\n$1' }) + validate_state('i', { 'Selected=sel', '', '1', child.fn.getcwd(), '' }, { 5, 0 }) +end + +T['default_insert()']['respects `opts.empty_tabstop` and `opts.empty_tabstop_final`'] = function() + default_insert({ body = 'T1=$1 T2=$2 T0=$0' }, { empty_tabstop = '!', empty_tabstop_final = '?' }) + child.expect_screenshot() +end + +T['default_insert()']['respects `opts.lookup`'] = function() + local lookup = { AAA = 'aaa', TM_SELECTED_TEXT = 'xxx', ['1'] = 'tabstop' } + default_insert({ body = '$AAA $TM_SELECTED_TEXT $1 $1 $2' }, { lookup = lookup }) + child.expect_screenshot() + -- Looked up tabstop text should be treated as if user typed it (i.e. proper + -- cursor position and no placeholder) + eq(get_cursor(), { 1, 15 }) + eq(get().nodes[5].text, 'tabstop') +end + +T['default_insert()']['validates input'] = function() + expect.error(function() default_insert('Text') end, '`snippet`.*snippet table') + expect.error(function() default_insert({ body = 'Text' }, { empty_tabstop = 1 }) end, '`empty_tabstop`.*string') + expect.error( + function() default_insert({ body = 'Text' }, { empty_tabstop_final = 1 }) end, + '`empty_tabstop_final`.*string' + ) + expect.error(function() default_insert({ body = 'Text' }, { lookup = 1 }) end, '`lookup`.*table') + + expect.error(function() default_insert({ body = '${1|}' }) end, 'Tabstop with choices') +end + +T['session.get()'] = new_set() + +T['session.get()']['works'] = function() + -- Should work without active session + eq(get(), vim.NIL) + + default_insert({ body = 'T1=${1:<$2>}' }, { empty_tabstop = '$' }) + local session = get() + + -- Should return correct data structure + local fields = vim.tbl_keys(session) + table.sort(fields) + eq(fields, { 'buf_id', 'cur_tabstop', 'extmark_id', 'insert_args', 'nodes', 'ns_id', 'tabstops' }) + + local cur_buf = get_buf() + local ref_partial_session = { + buf_id = cur_buf, + cur_tabstop = '1', + insert_args = { + snippet = { body = 'T1=${1:<$2>}' }, + opts = { empty_tabstop = '$', empty_tabstop_final = '∎', lookup = {} }, + }, + tabstops = { + ['0'] = { is_visited = false, prev = '2', next = '1' }, + ['1'] = { is_visited = true, prev = '0', next = '2' }, + ['2'] = { is_visited = false, prev = '1', next = '0' }, + }, + } + eq_partial_tbl(session, ref_partial_session) + + -- Should return valid namespace for present extmarks + local ns_id, is_valid_ns_id = session.ns_id, false + for _, id in pairs(child.api.nvim_get_namespaces()) do + is_valid_ns_id = is_valid_ns_id or id == ns_id + end + eq(is_valid_ns_id, true) + + -- Should have correct session extmark + local get_extmark = make_get_extmark(session) + local ref_extmark = { row = 0, col = 0, end_row = 0, end_col = 5, right_gravity = false, end_right_gravity = true } + eq_partial_tbl(get_extmark(session.extmark_id), ref_extmark) + + -- Should have proper node structure with correct extmarks attached to nodes + local has_inline_extmarks = child.fn.has('nvim-0.10') == 1 + --stylua: ignore + local ref_nodes = { + { text = 'T1=', extmark = { row = 0, col = 0, end_row = 0, end_col = 3 } }, + { + tabstop = '1', + extmark = { row = 0, col = 3, end_row = 0, end_col = 5, right_gravity = false, end_right_gravity = true }, + placeholder = { + { text = '<', extmark = { row = 0, col = 3, end_row = 0, end_col = 4 } }, + { + tabstop = '2', + extmark = { + row = 0, col = 4, end_row = 0, end_col = 4, + virt_text = has_inline_extmarks and { { '$', 'MiniSnippetsCurrentReplace' } } or nil, + virt_text_pos = has_inline_extmarks and 'inline' or nil, + }, + placeholder = { + { text = '', extmark = { row = 0, col = 4, end_row = 0, end_col = 4 } } + }, + }, + { text = '>', extmark = { row = 0, col = 4, end_row = 0, end_col = 5 } }, + } + }, + { + tabstop = '0', + placeholder = { { text = '', extmark = { row = 0, col = 5, end_row = 0, end_col = 5 } } }, + extmark = { + row = 0, col = 5, end_row = 0, end_col = 5, + virt_text = has_inline_extmarks and { { '∎', 'MiniSnippetsFinal' } } or nil, + virt_text_pos = has_inline_extmarks and 'inline' or nil, + } + } + } + validate_session_nodes_partial(session, ref_nodes) + + -- Should update nodes immediately if they are removed + type_keys('x') + session = get() + ref_nodes = { + { text = 'T1=', extmark = { row = 0, col = 0, end_row = 0, end_col = 3 } }, + { tabstop = '1', text = 'x', extmark = { row = 0, col = 3, end_row = 0, end_col = 4 } }, + { tabstop = '0', placeholder = { { text = '' } }, extmark = { row = 0, col = 4, end_row = 0, end_col = 4 } }, + } + validate_session_nodes_partial(session, ref_nodes) + + -- Session's tabstop can be used to track session's total region + get_extmark = make_get_extmark(session) + eq_partial_tbl(get_extmark(session.extmark_id), { row = 0, col = 0, end_row = 0, end_col = 4 }) + + -- Should return copy of the session data + local is_copy = child.lua([[ + local session = MiniSnippets.session.get() + local ref_cur_tabstop = session.cur_tabstop + session.cur_tabstop = -1 + return MiniSnippets.session.get().cur_tabstop == ref_cur_tabstop + ]]) + eq(is_copy, true) +end + +T['session.get()']['reflects up to date tabstop data after jumps'] = function() + local validate_tabstops = function(ref_cur_tabstop, ref_visited) + local session = get() + eq(session.cur_tabstop, ref_cur_tabstop) + local out_visited = {} + for id, data in pairs(get().tabstops) do + out_visited[id] = data.is_visited + end + eq(out_visited, ref_visited) + end + + default_insert({ body = 'T1=$1 T2=$2 T0=$0' }) + validate_tabstops('1', { ['1'] = true, ['2'] = false, ['0'] = false }) + jump('next') + validate_tabstops('2', { ['1'] = true, ['2'] = true, ['0'] = false }) + -- Already visited should keep returning `true` + jump('prev') + validate_tabstops('1', { ['1'] = true, ['2'] = true, ['0'] = false }) + jump('prev') + validate_tabstops('0', { ['1'] = true, ['2'] = true, ['0'] = true }) +end + +T['session.get()']['respects `all` argument'] = function() + default_insert({ body = 'T1=$1 T0=$0' }) + default_insert({ body = 'U1=$1 U0=$0' }) + local sessions = get(true) + eq(#sessions, 2) + + local cur_buf = get_buf() + eq_partial_tbl(sessions, { + { buf_id = cur_buf, cur_tabstop = '1', insert_args = { snippet = { body = 'T1=$1 T0=$0' } } }, + { buf_id = cur_buf, cur_tabstop = '1', insert_args = { snippet = { body = 'U1=$1 U0=$0' } } }, + }) + + -- Previous session's extmarks should still be tracking + eq_partial_tbl(make_get_extmark(sessions[1])(sessions[1].extmark_id), { row = 0, col = 0, end_row = 0, end_col = 14 }) + type_keys('x') + eq_partial_tbl(make_get_extmark(sessions[1])(sessions[1].extmark_id), { row = 0, col = 0, end_row = 0, end_col = 15 }) +end + +T['session.jump()'] = new_set() + +local validate_jumps = function(jump_data_arr) + for _, data in ipairs(jump_data_arr) do + jump(data[1]) + eq(get_cur_tabstop(), data[2]) + if data[3] ~= nil then eq(get_cursor(), data[3]) end + end +end + +T['session.jump()']['works'] = function() + default_insert({ body = 'T1=$1 T0=$0' }) + -- Jumping to tabstop with placeholder should put cursor at placeholder start + -- Also should wrap tabstops around the end + validate_jumps({ { 'next', '0', { 1, 7 } }, { 'next', '1', { 1, 3 } } }) + validate_jumps({ { 'prev', '0', { 1, 7 } }, { 'prev', '1', { 1, 3 } } }) + + -- Should not error without active session + stop() + eq(jump('next'), vim.NIL) + eq(jump('prev'), vim.NIL) +end + +T['session.jump()']['does not lead to replacing already edited tabstop'] = function() + default_insert({ body = 'T1=${1:}\nT0=$0' }) + type_keys('yyy') + validate_state('i', { 'T1=yyy', 'T0=' }, { 1, 6 }) + + jump('next') + jump('prev') + eq(get_cursor(), { 1, 6 }) + type_keys('!') + validate_state('i', { 'T1=yyy!', 'T0=' }, { 1, 7 }) + + -- Should not matter where cursor was when target tabstop was current + type_keys('') + eq(get_cursor(), { 2, 3 }) + jump('prev') + jump('next') + eq(get_cursor(), { 1, 7 }) +end + +T['session.jump()']['works with several linked tabstops'] = function() + default_insert({ body = 'T1=${1:<$0>} T1=$1 T0=$0' }) + + -- Should jump only to the first node of target tabstop + validate_jumps({ { 'next', '0', { 1, 4 } }, { 'next', '1', { 1, 3 } } }) + validate_jumps({ { 'prev', '0', { 1, 4 } }, { 'prev', '1', { 1, 3 } } }) + + -- Even if it changes + type_keys('x') + validate_state('i', { 'T1=x T1=x T0=' }, { 1, 4 }) + validate_jumps({ { 'next', '0', { 1, 13 } }, { 'next', '1', { 1, 4 } } }) +end + +T['session.jump()']['jumps in proper order'] = function() + default_insert({ body = 'T2=$2 T0=$0 T1=$1' }) + validate_state('i', { 'T2= T0= T1=' }, { 1, 11 }) + validate_jumps({ { 'next', '2', { 1, 3 } }, { 'next', '0', { 1, 7 } }, { 'next', '1', { 1, 11 } } }) + validate_jumps({ { 'prev', '0', { 1, 7 } }, { 'prev', '2', { 1, 3 } }, { 'prev', '1', { 1, 11 } } }) +end + +T['session.jump()']['works with tabstop with transform'] = function() + -- Should ignore present transform (for now) and treat as regular tabstop + default_insert({ body = '$1 ${2/.*/upcase/} $0' }) + validate_jumps({ { 'next', '2', { 1, 1 } }, { 'next', '0', { 1, 2 } } }) +end + +T['session.jump()']['ignores variable nodes'] = function() + default_insert({ body = 'T1=$1 $AAA T2=$2 $BBB' }, { lookup = { AAA = 'aaa' } }) + validate_state('i', { 'T1= aaa T2= ' }, { 1, 3 }) + validate_jumps({ { 'next', '2', { 1, 11 } }, { 'next', '0', { 1, 12 } }, { 'next', '1', { 1, 3 } } }) +end + +T['session.jump()']['ensures session buffer is current'] = function() + default_insert({ body = 'T1=$1 T0=$0' }) + type_keys('') + + -- Prepare separate buffers and windows + local buf_id_1, buf_id_2 = get_buf(), new_buf() + local win_1 = child.api.nvim_get_current_win() + child.cmd('vertical split') + local win_2 = child.api.nvim_get_current_win() + no_eq(win_1, win_2) + child.api.nvim_win_set_buf(win_2, buf_id_2) + + -- Should reuse visible window + eq(child.api.nvim_get_current_win(), win_2) + eq(child.fn.mode(), 'n') + jump('next') + -- - Poke eventloop because both ensuring Insert mode from Normal mode and + -- jumping do not happen immediately + child.poke_eventloop() + eq(child.api.nvim_get_current_win(), win_1) + eq(child.api.nvim_win_is_valid(win_2), true) + -- Should ensure Insert mode + validate_state('i', { 'T1= T0=' }, { 1, 7 }) + eq(get_cur_tabstop(), '0') + + -- Should show target buffer in current window if not visible + child.api.nvim_win_set_buf(0, buf_id_2) + eq(child.fn.win_findbuf(buf_id_1), {}) + jump('prev') + eq(get_buf(), buf_id_1) + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + eq(get_cur_tabstop(), '1') +end + +T['session.jump()']['shows completion for tabstop with choices'] = function() + default_insert({ body = 'T1=${1|aa,bb|} T2=${2|dd,cc|}' }) + validate_pumitems({ 'aa', 'bb' }) + jump('next') + validate_pumitems({ 'dd', 'cc' }) + jump('prev') + validate_pumitems({ 'aa', 'bb' }) +end + +T['session.jump()']['handles when tabstop becomes absent'] = function() + default_insert({ body = '${1:$2} ${3:$0}' }) + type_keys('x') + validate_jumps({ { 'next', '3' }, { 'next', '0' }, { 'next', '1' } }) + validate_jumps({ { 'prev', '0' }, { 'prev', '3' }, { 'prev', '1' } }) + + jump('next') + type_keys('y') + validate_jumps({ { 'next', '1' }, { 'next', '3' } }) + validate_jumps({ { 'prev', '1' }, { 'prev', '3' } }) +end + +T['session.jump()']['validates input'] = function() + expect.error(function() jump(1) end, '`direction`.*one of') +end + +--stylua: ignore +T['session.jump()']['triggers events'] = function() + child.lua([[ + local events = { 'MiniSnippetsSessionJumpPre', 'MiniSnippetsSessionJump' } + _G.au_log = {} + local track = function(args) + local entry = { + event = args.match, + buf_id = args.buf, + data = args.data, + cur_tabstop = MiniSnippets.session.get().cur_tabstop, + } + table.insert(_G.au_log, entry) + end + vim.api.nvim_create_autocmd('User', { pattern = events, callback = track }) + ]]) + + local cur_buf = get_buf() + default_insert({ body = 'T1=$1 T0=$0' }) + -- Should not trigger during initial insert + eq(get_au_log(), {}) + + jump('next') + local ref_au_log = { + -- `*Pre` should be called *before* changing current tabstop + { event = 'MiniSnippetsSessionJumpPre', cur_tabstop = '1', data = { tabstop_from = '1', tabstop_to = '0' }, buf_id = cur_buf }, + { event = 'MiniSnippetsSessionJump', cur_tabstop = '0', data = { tabstop_from = '1', tabstop_to = '0' }, buf_id = cur_buf }, + } + eq(get_au_log(), ref_au_log) + + jump('next') + vim.list_extend(ref_au_log, { + { event = 'MiniSnippetsSessionJumpPre', cur_tabstop = '0', data = { tabstop_from = '0', tabstop_to = '1' }, buf_id = cur_buf }, + { event = 'MiniSnippetsSessionJump', cur_tabstop = '1', data = { tabstop_from = '0', tabstop_to = '1' }, buf_id = cur_buf }, + }) + eq(get_au_log(), ref_au_log) + + stop() + clean_au_log() + + -- Should still trigger events if there is only a single tabstop left + default_insert({ body = 'T1=${1:$0}' }) + type_keys('x') + jump('next') + ref_au_log = { + { event = 'MiniSnippetsSessionJumpPre', cur_tabstop = '1', data = { tabstop_from = '1', tabstop_to = '1' }, buf_id = cur_buf }, + { event = 'MiniSnippetsSessionJump', cur_tabstop = '1', data = { tabstop_from = '1', tabstop_to = '1' }, buf_id = cur_buf }, + } + eq(get_au_log(), ref_au_log) +end + +T['session.stop()'] = new_set() + +T['session.stop()']['works'] = function() + -- Should work without active session + expect.no_error(stop) + + default_insert({ body = 'T1=$1 T0=$0' }) + default_insert({ body = 'U1=$1 U0=$0' }) + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 6 }) + validate_n_sessions(2) + child.expect_screenshot() + + -- Should stop active session (no change mode/cursor) and resume previous + stop() + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 6 }) + validate_n_sessions(1) + child.expect_screenshot() + + stop() + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 6 }) + validate_n_sessions(0) + child.expect_screenshot() + + -- Should clean all side effects + expect.error(function() child.cmd('au MiniSnippetsTrack') end, 'No such group') + expect.match(child.cmd_capture('imap '), 'No mapping') + expect.match(child.cmd_capture('imap '), 'No mapping') + expect.match(child.cmd_capture('imap '), 'No mapping') +end + +T['session.stop()']['hides completion popup'] = function() + default_insert({ body = 'T1=$1 T0=$0' }) + type_keys('') + validate_pumvisible() + stop() + validate_no_pumvisible() + eq(child.fn.mode(), 'i') +end + +T['parse()'] = new_set() + +local parse = forward_lua('MiniSnippets.parse') + +T['parse()']['works'] = function() + --stylua: ignore + eq( + parse('hello ${1:xx} $var world$0'), + { + { text = 'hello ' }, { tabstop = '1', placeholder = { { text = 'xx' } } }, { text = ' ' }, + { var = 'var' }, { text = ' world' }, { tabstop = '0' }, + } + ) + -- Should allow array of strings + eq(parse({ 'aa', '$1', '$var' }), { { text = 'aa\n' }, { tabstop = '1' }, { text = '\n' }, { var = 'var' } }) +end + +--stylua: ignore +T['parse()']['text'] = function() + -- Common + eq(parse('aa'), { { text = 'aa' } }) + eq(parse('ыыы ффф'), { { text = 'ыыы ффф' } }) + + -- Simple + eq(parse(''), { { text = '' } }) + eq(parse('$'), { { text = '$' } }) + eq(parse('{'), { { text = '{' } }) + eq(parse('}'), { { text = '}' } }) + eq(parse([[\]]), { { text = [[\]] } }) + + -- Escaped (should ignore `\` before `$}\`) + eq(parse([[aa\$bb\}cc\\dd]]), { { text = [[aa$bb}cc\dd]] } }) + eq(parse([[aa\$]]), { { text = 'aa$' } }) + eq(parse([[aa\${}]]), { { text = 'aa${}' } }) + eq(parse([[\}]]), { { text = '}' } }) + eq(parse([[aa \\\$]]), { { text = [[aa \$]] } }) + eq(parse([[\${1|aa,bb|}]]), { { text = '${1|aa,bb|}' } }) + + -- Not spec: allow unescaped backslash + eq(parse([[aa\bb]]), { { text = [[aa\bb]] } }) + + -- Not spec: allow unescaped $ when can not be mistaken for tabstop or var + eq(parse('aa$ bb'), { { text = 'aa$ bb' } }) + + -- Allow '$' at the end of the snippet + eq(parse('aa$'), { { text = 'aa' }, { text = '$' } }) + + -- Not spec: allow unescaped `}` in top-level text + eq(parse('{ aa }'), { { text = '{ aa }' } }) + eq(parse('{\n\taa\n}'), { { text = '{\n\taa\n}' } }) + eq(parse('aa{1}'), { { text = 'aa{1}' } }) + eq(parse('aa{1:bb}'), { { text = 'aa{1:bb}' } }) + eq(parse('aa{1:{2:cc}}'), { { text = 'aa{1:{2:cc}}' } }) + eq(parse('aa{var:{1:bb}}'), { { text = 'aa{var:{1:bb}}' } }) +end + +--stylua: ignore +T['parse()']['tabstop'] = function() + -- Common + eq(parse('$1'), { { tabstop = '1' } }) + eq(parse('aa $1'), { { text = 'aa ' }, { tabstop = '1' } }) + eq(parse('aa $1 bb'), { { text = 'aa ' }, { tabstop = '1' }, { text = ' bb' } }) + eq(parse('aa$1bb'), { { text = 'aa' }, { tabstop = '1' }, { text = 'bb' } }) + eq(parse('hello_$1_bb'), { { text = 'hello_' }, { tabstop = '1' }, { text = '_bb' } }) + eq(parse('ыыы $1 ффф'), { { text = 'ыыы ' }, { tabstop = '1' }, { text = ' ффф' } }) + + eq(parse('${1}'), { { tabstop = '1' } }) + eq(parse('aa ${1}'), { { text = 'aa ' }, { tabstop = '1' } }) + eq(parse('aa ${1} bb'), { { text = 'aa ' }, { tabstop = '1' }, { text = ' bb' } }) + eq(parse('aa${1}bb'), { { text = 'aa' }, { tabstop = '1' }, { text = 'bb' } }) + eq(parse('hello_${1}_bb'), { { text = 'hello_' }, { tabstop = '1' }, { text = '_bb' } }) + eq(parse('ыыы ${1} ффф'), { { text = 'ыыы ' }, { tabstop = '1' }, { text = ' ффф' } }) + + eq(parse('$0'), { { tabstop = '0' } }) + eq(parse('$1 $0'), { { tabstop = '1' }, { text = ' ' }, { tabstop = '0' } }) + + eq(parse([[aa\\$1]]), { { text = [[aa\]] }, { tabstop = '1' } }) + + -- Adjacent tabstops + eq(parse('aa$1$2'), { { text = 'aa' }, { tabstop = '1' }, { tabstop = '2' } }) + eq(parse('aa$1$0'), { { text = 'aa' }, { tabstop = '1' }, { tabstop = '0' } }) + eq(parse('$1$2'), { { tabstop = '1' }, { tabstop = '2' } }) + eq(parse('${1}${2}'), { { tabstop = '1' }, { tabstop = '2' } }) + eq(parse('$1${2}'), { { tabstop = '1' }, { tabstop = '2' } }) + eq(parse('${1}$2'), { { tabstop = '1' }, { tabstop = '2' } }) + + -- Can be any digit sequence in any order + eq(parse('$2'), { { tabstop = '2' } }) + eq(parse('$3 $10'), { { tabstop = '3' }, { text = ' ' }, { tabstop = '10' } }) + eq(parse('$3 $2 $0'), { { tabstop = '3' }, { text = ' ' }, { tabstop = '2' }, { text = ' ' }, { tabstop = '0' } }) + eq(parse('$3 $0 $2'), { { tabstop = '3' }, { text = ' ' }, { tabstop = '0' }, { text = ' ' }, { tabstop = '2' } }) + eq(parse('$1 $01'), { { tabstop = '1' }, { text = ' ' }, { tabstop = '01' } }) + + -- Tricky + eq(parse('$1$a'), { { tabstop = '1' }, { var = 'a' } }) + eq(parse('$1$-'), { { tabstop = '1' }, { text = '$-' } }) + eq(parse('$a$1'), { { var = 'a' }, { tabstop = '1' } }) + eq(parse('$-$1'), { { text = '$-' }, { tabstop = '1' } }) + eq(parse('$$1'), { { text = '$' }, { tabstop = '1' } }) + eq(parse('$1$'), { { tabstop = '1' }, { text = '$' } }) +end + +--stylua: ignore +T['parse()']['choice'] = function() + -- Common + eq(parse('${1|aa|}'), { { tabstop = '1', choices = { 'aa' } } }) + eq(parse('${2|aa|}'), { { tabstop = '2', choices = { 'aa' } } }) + eq(parse('${1|aa,bb|}'), { { tabstop = '1', choices = { 'aa', 'bb' } } }) + + -- Escape (should ignore `\` before `,|\` and treat as text) + eq(parse([[${1|},$,\,,\|,\\|}]]), { { tabstop = '1', choices = { '}', '$', ',', '|', [[\]] } } }) + eq(parse([[${1|aa\,bb|}]]), { { tabstop = '1', choices = { 'aa,bb' } } }) + + -- Empty choices + eq(parse('${1|,|}'), { { tabstop = '1', choices = { '', '' } } }) + eq(parse('${1|aa,|}'), { { tabstop = '1', choices = { 'aa', '' } } }) + eq(parse('${1|,aa|}'), { { tabstop = '1', choices = { '', 'aa' } } }) + eq(parse('${1|aa,,bb|}'), { { tabstop = '1', choices = { 'aa', '', 'bb' } } }) + eq(parse('${1|aa,,,bb|}'), { { tabstop = '1', choices = { 'aa', '', '', 'bb' } } }) + + -- Not spec: allow unescaped backslash + eq(parse([[${1|aa\bb,cc|}]]), { { tabstop = '1', choices = { [[aa\bb]], 'cc' } } }) + + -- Should not be ignored in `$0` + eq(parse('${0|aa|}'), { { tabstop = '0', choices = { 'aa' } } }) + eq(parse('${0|aa,bb|}'), { { tabstop = '0', choices = { 'aa', 'bb' } } }) +end + +--stylua: ignore +T['parse()']['var'] = function() + -- Common + eq(parse('$aa'), { { var = 'aa' } }) + eq(parse('$a_b'), { { var = 'a_b' } }) + eq(parse('$_a'), { { var = '_a' } }) + eq(parse('$a1'), { { var = 'a1' } }) + eq(parse('${aa}'), { { var = 'aa' } }) + eq(parse('${a_b}'), { { var = 'a_b' } }) + eq(parse('${_a}'), { { var = '_a' } }) + eq(parse('${a1}'), { { var = 'a1' } }) + + eq(parse([[aa\\$bb]]), { { text = [[aa\]] }, { var = 'bb' } }) + eq(parse('$$aa'), { { text = '$' }, { var = 'aa' } }) + eq(parse('$aa$'), { { var = 'aa' }, { text = '$' } }) + + -- Should recognize only [_a-zA-Z] [_a-zA-Z0-9]* + eq(parse('$aa-bb'), { { var = 'aa' }, { text = '-bb' } }) + eq(parse('$aa bb'), { { var = 'aa' }, { text = ' bb' } }) + eq(parse('aa$bb cc'), { { text = 'aa' }, { var = 'bb' }, { text = ' cc' } }) + eq(parse('aa${bb} cc'), { { text = 'aa' }, { var = 'bb' }, { text = ' cc' } }) +end + +--stylua: ignore +T['parse()']['placeholder'] = function() + -- Common + eq(parse('aa ${1:b}'), { { text = 'aa ' }, { tabstop = '1', placeholder = { { text = 'b' } } } }) + eq(parse('${1:b}'), { { tabstop = '1', placeholder = { { text = 'b' } } } }) + eq(parse('${1:ыыы}'), { { tabstop = '1', placeholder = { { text = 'ыыы' } } } }) + eq(parse('${1:}'), { { tabstop = '1', placeholder = { { text = '' } } } }) + + eq(parse('${1:aa} ${2:bb}'), { { tabstop = '1', placeholder = { { text = 'aa' } } }, { text = ' ' }, { tabstop = '2', placeholder = { { text = 'bb' } } } }) + + eq(parse('aa ${0:b}'), { { text = 'aa ' }, { tabstop = '0', placeholder = { { text = 'b' } } } }) + eq(parse('${0:b}'), { { tabstop = '0', placeholder = { { text = 'b' } } } }) + eq(parse('${0:}'), { { tabstop = '0', placeholder = { { text = '' } } } }) + eq(parse('${0:ыыы}'), { { tabstop = '0', placeholder = { { text = 'ыыы' } } } }) + eq(parse('${0:}'), { { tabstop = '0', placeholder = { { text = '' } } } }) + + -- Escaped (should ignore `\` before `$}\` and treat as text) + eq(parse([[${1:aa\$bb\}cc\\dd}]]), { { tabstop = '1', placeholder = { { text = [[aa$bb}cc\dd]] } } } }) + eq(parse([[${1:aa\$}]]), { { tabstop = '1', placeholder = { { text = 'aa$' } } } }) + eq(parse([[${1:aa\\}]]), { { tabstop = '1', placeholder = { { text = [[aa\]] } } } }) + -- - Should allow unescaped `:` + eq(parse('${1:aa:bb}'), { { tabstop = '1', placeholder = { { text = 'aa:bb' } } } }) + + -- Not spec: allow unescaped backslash + eq(parse([[${1:aa\bb}]]), { { tabstop = '1', placeholder = { { text = [[aa\bb]] } } } }) + + -- Not spec: allow unescaped dollar + eq(parse('${1:aa$-}'), { { tabstop = '1', placeholder = { { text = 'aa$-' } } } }) + eq(parse('${1:aa$}'), { { tabstop = '1', placeholder = { { text = 'aa$' } } } }) + eq(parse('${1:$2$}'), { { tabstop = '1', placeholder = { { tabstop = '2' }, { text = '$' } } } }) + eq(parse('${1:$2}$'), { { tabstop = '1', placeholder = { { tabstop = '2' } } }, { text = '$' } }) + eq(parse('${1:aa$}$2'), { { tabstop = '1', placeholder = { { text = 'aa$' } } }, { tabstop = '2' } }) + + -- Should not be ignored in `$0` + eq(parse('${0:aa$1bb}'), { { tabstop = '0', placeholder = { { text = 'aa' }, { tabstop = '1' }, { text = 'bb' } } } }) + + -- Placeholder for variable (assume implemented the same way as for tabstop) + eq(parse('${aa:}'), { { var = 'aa', placeholder = { { text = '' } } } }) + eq(parse('${aa:bb}'), { { var = 'aa', placeholder = { { text = 'bb' } } } }) + eq(parse('${aa:bb:cc}'), { { var = 'aa', placeholder = { { text = 'bb:cc' } } } }) + eq(parse('${aa:$1}'), { { var = 'aa', placeholder = { { tabstop = '1' } } } }) + eq(parse('${aa:${1}}'), { { var = 'aa', placeholder = { { tabstop = '1' } } } }) + eq(parse('${aa:${1:bb}}'), { { var = 'aa', placeholder = { { tabstop = '1', placeholder = { { text = 'bb' } } } } } }) + eq(parse('${aa:${1|bb|}}'), { { var = 'aa', placeholder = { { tabstop = '1', choices = { 'bb' } } } } }) + eq(parse('${aa:${bb:cc}}'), { { var = 'aa', placeholder = { { var = 'bb', placeholder = { { text = 'cc' } } } } } }) + + -- Nested + -- - Tabstop + eq(parse('${1:$2}'), { { tabstop = '1', placeholder = { { tabstop = '2' } } } }) + eq(parse('${1:$2} yy'), { { tabstop = '1', placeholder = { { tabstop = '2' } } }, { text = ' yy' } }) + eq(parse('${1:${2}}'), { { tabstop = '1', placeholder = { { tabstop = '2' } } } }) + eq(parse('${1:${3}}'), { { tabstop = '1', placeholder = { { tabstop = '3' } } } }) + + -- - Placeholder + eq(parse('${1:${2:aa}}'), { { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } } } } }) + eq(parse('${1:${2:${3:aa}}}'), { { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { tabstop = '3', placeholder = { { text = 'aa' } } } } } } } }) + eq(parse('${1:${2:${3}}}'), { { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { tabstop = '3' } } } } } }) + eq(parse('${1:${3:aa}}'), { { tabstop = '1', placeholder = { { tabstop = '3', placeholder = { { text = 'aa' } } } } } }) + + eq(parse([[${1:${2:aa\$bb\}cc\\dd}}]]), { { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { text = [[aa$bb}cc\dd]] } } } } } }) + + -- - Choice + eq(parse('${1:${2|aa|}}'), { { tabstop = '1', placeholder = { { tabstop = '2', choices = { 'aa' } } } } }) + eq(parse('${1:${3|aa|}}'), { { tabstop = '1', placeholder = { { tabstop = '3', choices = { 'aa' } } } } }) + eq(parse('${1:${2|aa,bb|}}'), { { tabstop = '1', placeholder = { { tabstop = '2', choices = { 'aa', 'bb' } } } } }) + + eq(parse([[${1:${2|aa\,bb\|cc\\dd|}}]]), { { tabstop = '1', placeholder = { { tabstop = '2', choices = { [[aa,bb|cc\dd]] } } } } }) + + -- - Variable + eq(parse('${1:$aa}'), { { tabstop = '1', placeholder = { { var = 'aa' } } } }) + eq(parse('${1:$aa} xx'), { { tabstop = '1', placeholder = { { var = 'aa' } } }, { text = ' xx' } }) + eq(parse('${1:${aa}}'), { { tabstop = '1', placeholder = { { var = 'aa' } } } }) + eq(parse('${1:${aa:bb}}'), { { tabstop = '1', placeholder = { { var = 'aa', placeholder = { { text = 'bb' } } } } } }) + eq(parse('${1:${aa:$2}}'), { { tabstop = '1', placeholder = { { var = 'aa', placeholder = { { tabstop = '2' } } } } } }) + eq(parse('${1:${aa:bb$2cc}}'), { { tabstop = '1', placeholder = { { var = 'aa', placeholder = { { text = 'bb' }, { tabstop = '2' }, { text = 'cc' } } } } } }) + eq(parse('${1:${aa/.*/val/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', 'val', 'i' } } } } }) + eq(parse('${1:${aa/.*/${1}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', '${1}', 'i' } } } } }) + eq(parse('${1:${aa/.*/${1:/upcase}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', '${1:/upcase}', 'i' } } } } }) + eq(parse('${1:${aa/.*/${1:/upcase}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', '${1:/upcase}', 'i' } } } } }) + + eq(parse('${1:${aa/.*/xx${1:else}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', 'xx${1:else}', 'i' } } } } }) + eq(parse('${1:${aa/.*/xx${1:-else}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', 'xx${1:-else}', 'i' } } } } }) + eq(parse('${1:${aa/.*/xx${1:+if}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', 'xx${1:+if}', 'i' } } } } }) + eq(parse('${1:${aa/.*/xx${1:?if:else}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', 'xx${1:?if:else}', 'i' } } } } }) + eq(parse('${1:${aa/.*/xx${1:/upcase}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', 'xx${1:/upcase}', 'i' } } } } }) + + eq(parse('${1:${aa/.*/${1:?${}:xx}/i}}'), { { tabstop = '1', placeholder = { { var = 'aa', transform = { '.*', '${1:?${}:xx}', 'i' } } } } }) + + -- - Known limitation of needing to escape `}` in `if` + eq(parse([[${1:${aa/regex/${1:?if\}:else/i}/options}}]]), { { tabstop = '1', placeholder = { { var = 'aa', transform = { 'regex', [[${1:?if\}:else/i}]], 'options' } } } } }) + expect.no_equality(parse([[${1:${aa/regex/${1:?if}:else/i}/options}}]]), { { tabstop = '1', placeholder = { { var = 'aa', transform = { 'regex', '${1:?if}:else/i}', 'options' } } } } }) -- this is bad + + -- Combined + eq(parse('${1:aa${2:bb}cc}'), { { tabstop = '1', placeholder = { { text = 'aa' }, { tabstop = '2', placeholder = { { text = 'bb' } } }, { text = 'cc' } } } }) + eq(parse('${1:aa $aa bb}'), { { tabstop = '1', placeholder = { { text = 'aa ' }, { var = 'aa' }, { text = ' bb' } } } }) + eq(parse('${1:aa${aa:xx}bb}'), { { tabstop = '1', placeholder = { { text = 'aa' }, { var = 'aa', placeholder = { { text = 'xx' } } }, { text = 'bb' } } } }) + eq(parse('${1:xx$bb}yy'), { { tabstop = '1', placeholder = { { text = 'xx' }, { var = 'bb' } } }, { text = 'yy'} }) + eq(parse('${aa:xx$bb}yy'), { { var = 'aa', placeholder = { { text = 'xx' }, { var = 'bb' } } }, { text = 'yy'} }) + + -- Different placeholders for same id/name + eq( + parse('${1:xx}_${1:yy}_$1'), + { { tabstop = '1', placeholder = { { text = 'xx' } } }, { text = '_' }, { tabstop = '1', placeholder = { { text = 'yy' } } }, { text = '_' }, { tabstop = '1' } } + ) + eq( + parse('${1:}_$1_${1:yy}'), + { { tabstop = '1', placeholder = { { text = '' } } }, { text = '_' }, { tabstop = '1' }, { text = '_' }, { tabstop = '1', placeholder = { { text = 'yy' } } } } + ) + + eq( + parse('${a:xx}_${a:yy}_$a'), + { { var = 'a', placeholder = { { text = 'xx' } } }, { text = '_' }, { var = 'a', placeholder = { { text = 'yy' } } }, { text = '_' }, { var = 'a' } } + ) + eq( + parse('${a:}-$a-${a:yy}'), + { { var = 'a', placeholder = { { text = '' } } }, { text = '-' }, { var = 'a' }, { text = '-' }, { var = 'a', placeholder = { { text = 'yy' } } } } + ) +end + +--stylua: ignore +T['parse()']['transform'] = function() + -- All transform string should be parsed as is + + -- Should be allowed in variable nodes + eq(parse('${var/xx(yy)/${0:aaa}/i}'), { { var = 'var', transform = { 'xx(yy)', '${0:aaa}', 'i' } } }) + + eq(parse('${var/.*/${1}/i}'), { { var = 'var', transform = { '.*', '${1}', 'i' } } }) + eq(parse('${var/.*/$1/i}'), { { var = 'var', transform = { '.*', '$1', 'i' } } }) + eq(parse('${var/.*/$1/}'), { { var = 'var', transform = { '.*', '$1', '' } } }) + eq(parse('${var/.*//}'), { { var = 'var', transform = { '.*', '', '' } } }) + eq(parse('${var/.*/This-$1-encloses/i}'), { { var = 'var', transform = { '.*', 'This-$1-encloses', 'i' } } }) + eq(parse('${var/.*/aa${1:else}/i}'), { { var = 'var', transform = { '.*', 'aa${1:else}', 'i' } } }) + eq(parse('${var/.*/aa${1:-else}/i}'), { { var = 'var', transform = { '.*', 'aa${1:-else}', 'i' } } }) + eq(parse('${var/.*/aa${1:+if}/i}'), { { var = 'var', transform = { '.*', 'aa${1:+if}', 'i' } } }) + eq(parse('${var/.*/aa${1:?if:else}/i}'), { { var = 'var', transform = { '.*', 'aa${1:?if:else}', 'i' } } }) + eq(parse('${var/.*/aa${1:/upcase}/i}'), { { var = 'var', transform = { '.*', 'aa${1:/upcase}', 'i' } } }) + + -- Tricky transform strings + eq(parse('${var///}'), { { var = 'var', transform = { '', '', '' } } }) + + eq(parse([[${var/.*/$\//i}]]), { { var = 'var', transform = { '.*', [[$\/]], 'i' } } }) + eq(parse('${var/.*/$${}/i}'), { { var = 'var', transform = { '.*', '$${}', 'i' } } }) -- `${}` directly after `$` + eq(parse('${var/.*/${a/}/i}'), { { var = 'var', transform = { '.*', '${a/}', 'i' } } }) -- `/` inside a proper `${...}` + eq(parse([[${var/.*/$\x/i}]]), { { var = 'var', transform = { '.*', [[$\x]], 'i' } } }) -- `/` after both dollar and backslash + eq(parse([[${var/.*/\$x/i}]]), { { var = 'var', transform = { '.*', [[\$x]], 'i' } } }) -- `/` after both dollar and backslash + eq(parse([[${var/.*/\${x/i}]]), { { var = 'var', transform = { '.*', [[\${x]], 'i' } } }) -- `/` after not proper `${` + eq(parse([[${var/.*/$\{x/i}]]), { { var = 'var', transform = { '.*', [[$\{x]], 'i' } } }) -- `/` after not proper `${` + eq(parse('${var/.*/a$/i}'), { { var = 'var', transform = { '.*', 'a$', 'i' } } }) -- `/` directly after dollar + eq(parse('${var/.*/${1:?${}:aa}/i}'), { { var = 'var', transform = { '.*', '${1:?${}:aa}', 'i' } } }) -- `}` inside `format` + + -- Escaped (should ignore `\` before `$/\` and treat as text) + eq(parse([[${var/.*/\/a\/a\//g}]]), { { var = 'var', transform = { '.*', [[\/a\/a\/]], 'g' } } }) + + -- - Known limitation of needing to escape `}` in `if` of `${1:?if:else}` + eq(parse([[${var/.*/${1:?if\}:else/i}/options}]]), { { var = 'var', transform = { '.*', [[${1:?if\}:else/i}]], 'options' } } }) + expect.no_equality(parse([[${var/.*/${1:?if}:else/i}/options}]]), { { var = 'var', transform = { '.*', [[${1:?if}:else/i}]], 'options' } } }) -- this is bad + + eq(parse([[${var/.*/\\aa/g}]]), { { var = 'var', transform = { '.*', [[\\aa]], 'g' } } }) + eq(parse([[${var/.*/\$1aa/g}]]), { { var = 'var', transform = { '.*', [[\$1aa]], 'g' } } }) + + -- - Should handle escaped `/` in regex + eq(parse([[${var/\/re\/gex\//aa/}]]), { { var = 'var', transform = { [[\/re\/gex\/]], 'aa', '' } } }) + + -- Should be allowed in tabstop nodes + eq(parse('${1/.*/${0:aaa}/i} xx'), { { tabstop = '1', transform = { '.*', '${0:aaa}', 'i' } }, { text = ' xx' } }) + eq(parse('${1/.*/${1}/i}'), { { tabstop = '1', transform = { '.*', '${1}', 'i' } } }) + eq(parse('${1/.*/$1/i}'), { { tabstop = '1', transform = { '.*', '$1', 'i' } } }) + eq(parse('${1/.*/$1/}'), { { tabstop = '1', transform = { '.*', '$1', '' } } }) + eq(parse('${1/.*//}'), { { tabstop = '1', transform = { '.*', '', '' } } }) + eq(parse('${1/.*/This-$1-encloses/i}'), { { tabstop = '1', transform = { '.*', 'This-$1-encloses', 'i' } } }) + eq(parse('${1/.*/aa${1:else}/i}'), { { tabstop = '1', transform = { '.*', 'aa${1:else}', 'i' } } }) + eq(parse('${1/.*/aa${1:-else}/i}'), { { tabstop = '1', transform = { '.*', 'aa${1:-else}', 'i' } } }) + eq(parse('${1/.*/aa${1:+if}/i}'), { { tabstop = '1', transform = { '.*', 'aa${1:+if}', 'i' } } }) + eq(parse('${1/.*/aa${1:?if:else}/i}'), { { tabstop = '1', transform = { '.*', 'aa${1:?if:else}', 'i' } } }) + eq(parse('${1/.*/aa${1:/upcase}/i}'), { { tabstop = '1', transform = { '.*', 'aa${1:/upcase}', 'i' } } }) +end + +--stylua: ignore +T['parse()']['tricky'] = function() + eq(parse('${1:${aa:${1}}}'), { { tabstop = '1', placeholder = { { var = 'aa', placeholder = { { tabstop = '1' } } } } } }) + eq(parse('${1:${aa:bb$1cc}}'), { { tabstop = '1', placeholder = { { var = 'aa', placeholder = { { text = 'bb' }, { tabstop = '1' }, { text = 'cc' } } } } } }) + eq(parse([[${TM_DIRECTORY/.*src[\/](.*)/$1/}]]), { { var = 'TM_DIRECTORY', transform = { [[.*src[\/](.*)]], '$1', '' } } }) + eq(parse('${aa/(void$)|(.+)/${1:?-\treturn nil;}/}'), { { var = 'aa', transform = { '(void$)|(.+)', '${1:?-\treturn nil;}', '' } } }) + + eq( + parse('${3:nest1 ${1:nest2 ${2:nest3}}} $3'), + { + { tabstop = '3', placeholder = { { text = 'nest1 ' }, { tabstop = '1', placeholder = { { text = 'nest2 ' }, { tabstop = '2', placeholder = { { text = 'nest3' } } } } } } }, + { text = ' ' }, + { tabstop = '3' }, + } + ) + + eq( + parse('${1:prog}: ${2:$1.cc} - $2'), -- 'prog: .cc - ' + { + { tabstop = '1', placeholder = { { text = 'prog' } } }, + { text = ': ' }, + { tabstop = '2', placeholder = { { tabstop = '1' }, { text = '.cc' } } }, + { text = ' - ' }, + { tabstop = '2' }, + } + ) + eq( + parse('${1:prog}: ${3:${2:$1.cc}.33} - $2 $3'), -- 'prog: .cc.33 - ' + { + { tabstop = '1', placeholder = { { text = 'prog' } } }, + { text = ': ' }, + { tabstop = '3', placeholder = { { tabstop = '2', placeholder = { { tabstop = '1' }, { text = '.cc' } } }, { text = '.33' } } }, + { text = ' - ' }, + { tabstop = '2' }, + { text = ' ' }, + { tabstop = '3' }, + } + ) + eq( + parse('${1:$2.one} <> ${2:$1.two}'), -- '.one <> .two' + { + { tabstop = '1', placeholder = { { tabstop = '2' }, { text = '.one' } } }, + { text = ' <> ' }, + { tabstop = '2', placeholder = { { tabstop = '1' }, { text = '.two' } } }, + } + ) + + eq( + parse('$1 ${1:aaa} ${1|aa,bb|}'), + { + { tabstop = "1" }, + { text = " " }, + { tabstop = "1", placeholder = { { text = "aaa" } } }, + { text = " " }, + { tabstop = "1", choices = { "aa", "bb" } }, + } + ) +end + +--stylua: ignore +T['parse()']['respects `opts.normalize`'] = function() + local validate = function(snippet_body, ref_nodes) eq(parse(snippet_body, { normalize = true }), ref_nodes) end + local final_tabstop = { tabstop = '0', placeholder = { { text = '' } } } + + child.fn.setenv('AA', 'my-aa') + child.fn.setenv('XX', 'my-xx') + -- NOTE: on Windows setting environment variable to empty string is the same + -- as deleting it (at least until 2024-07-11 change which enables it) + child.fn.setenv('EMPTY', '') + + -- Resolves variables + validate('$AA', { { var = 'AA', text = 'my-aa' }, final_tabstop }) + validate('${AA}', { { var = 'AA', text = 'my-aa' }, final_tabstop }) + if not helpers.is_windows() then + validate('$EMPTY', { { var = 'EMPTY', text = '' }, final_tabstop }) + validate('${EMPTY:fallback}', { { var = 'EMPTY', text = '' }, final_tabstop }) + end + + -- Ensures text-or-placeholder + validate('$1', { { tabstop = '1', placeholder = { { text = '' } } }, final_tabstop }) + validate('${1}', { { tabstop = '1', placeholder = { { text = '' } } } , final_tabstop }) + validate('${1:val}', { { tabstop = '1', placeholder = { { text = 'val' } } }, final_tabstop }) + validate('${1/a/b/c}', { { tabstop = '1', placeholder = { { text = '' } }, transform = { 'a', 'b', 'c' } } , final_tabstop }) + -- - Should use first choice as placeholder + validate('${1|u,v|}', { { tabstop = '1', placeholder = { { text = 'u' } }, choices = { 'u', 'v' } } , final_tabstop }) + + validate('$BB', { { var = 'BB', placeholder = { { text = '' } } }, final_tabstop }) + validate('${BB}', { { var = 'BB', placeholder = { { text = '' } } }, final_tabstop }) + validate('${BB:var}', { { var = 'BB', placeholder = { { text = 'var' } } }, final_tabstop }) + validate('${BB/a/b/c}', { { var = 'BB', placeholder = { { text = '' } }, transform = { 'a', 'b', 'c' } }, final_tabstop }) + + -- - Should be exclusive OR + validate('${AA:var}', { { var = 'AA', text = 'my-aa' }, final_tabstop }) + validate('${AA:$1}', { { var = 'AA', text = 'my-aa' }, final_tabstop }) + validate('${AA:$XX}', { { var = 'AA', text = 'my-aa' }, final_tabstop }) + validate('${AA:${XX:var}}', { { var = 'AA', text = 'my-aa' }, final_tabstop }) + + validate('aa', { { text = 'aa' }, final_tabstop }) + + -- Should not append final tabstop if there is already one present (however deep) + validate('$0', { { tabstop = '0', placeholder = { { text = '' } } } }) + validate('${0:text}', { { tabstop = '0', placeholder = { { text = 'text' } } } }) + validate('$0$1', { { tabstop = '0', placeholder = { { text = '' } } }, { tabstop = '1', placeholder = { { text = '' } } } }) + validate('${0:text}$1', { { tabstop = '0', placeholder = { { text = 'text' } } }, { tabstop = '1', placeholder = { { text = '' } } } }) + validate('$0text', { { tabstop = '0', placeholder = { { text = '' } } }, { text = 'text' } }) + + -- - But only *exactly* '0' should be treated as final tabstop + validate('$00', { { tabstop = '00', placeholder = { { text = '' } } }, final_tabstop }) + + -- Should ensure same text in linked tabstops + validate('${1:aa}$1', { { tabstop = '1', placeholder = { { text = 'aa' } } }, { tabstop = '1', placeholder = { { text = 'aa' } } }, final_tabstop }) + validate('${1:aa}${1:bb}', { { tabstop = '1', placeholder = { { text = 'aa' } } }, { tabstop = '1', placeholder = { { text = 'aa' } } }, final_tabstop }) + validate('${1:aa}${1:$2}', { { tabstop = '1', placeholder = { { text = 'aa' } } }, { tabstop = '1', placeholder = { { text = 'aa' } } }, final_tabstop }) + validate('${1:aa}${1:${2:bb}}', { { tabstop = '1', placeholder = { { text = 'aa' } } }, { tabstop = '1', placeholder = { { text = 'aa' } } }, final_tabstop }) + validate('$1${1:aa}', { { tabstop = '1', placeholder = { { text = '' } } }, { tabstop = '1', placeholder = { { text = '' } } }, final_tabstop }) + validate('${1}${1:aa}', { { tabstop = '1', placeholder = { { text = '' } } }, { tabstop = '1', placeholder = { { text = '' } } }, final_tabstop }) + + validate('${1:${2:aa}}${2:$1}', { + { + tabstop = '1', + placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } } }, + }, + { tabstop = '2', placeholder = { { text = 'aa' } } }, + final_tabstop, + }) + validate('${2:${1:aa}}${1:$2}', { + { + tabstop = '2', + placeholder = { { tabstop = '1', placeholder = { { text = 'aa' } } } }, + }, + { tabstop = '1', placeholder = { { text = 'aa' } } }, + final_tabstop, + }) + + validate('${1:aa}${1:$2}', { { tabstop = '1', placeholder = { { text = 'aa' } } }, { tabstop = '1', placeholder = { { text = 'aa' } } }, final_tabstop }) + validate('${1:${2:aa}}$1', { + { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } } } }, + { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } } } }, + final_tabstop, + }) + validate('${1:${2:aa}}${2:x$1x}', { + { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } } } }, + { tabstop = '2', placeholder = { { text = 'aa' } } }, + final_tabstop, + }) + + validate('${1:$AA}${1:aa}', { + { tabstop = '1', placeholder = { { text = 'my-aa', var = 'AA' } } }, + { tabstop = '1', placeholder = { { text = 'my-aa', var = 'AA' } } }, + final_tabstop, + }) + + validate('${1:aa}$2${2:bb}$1', { + { tabstop = '1', placeholder = { { text = 'aa' } } }, + { tabstop = '2', placeholder = { { text = '' } } }, + { tabstop = '2', placeholder = { { text = '' } } }, + { tabstop = '1', placeholder = { { text = 'aa' } } }, + final_tabstop, + }) + validate('${1:${2:aa}bb}$2${2:bb}$1', { + { + tabstop = '1', + placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } }, { text = 'bb' } }, + }, + { tabstop = '2', placeholder = { { text = 'aa' } } }, + { tabstop = '2', placeholder = { { text = 'aa' } } }, + { + tabstop = '1', + placeholder = { { tabstop = '2', placeholder = { { text = 'aa' } } }, { text = 'bb' } }, + }, + final_tabstop, + }) + validate('${1:$AA}${2:$1}$1$2', { + { tabstop = '1', placeholder = { { text = 'my-aa', var = 'AA' } } }, + { + tabstop = '2', + placeholder = { { tabstop = '1', placeholder = { { text = 'my-aa', var = 'AA' } } } }, + }, + { tabstop = '1', placeholder = { { text = 'my-aa', var = 'AA' } } }, + { + tabstop = '2', + placeholder = { { tabstop = '1', placeholder = { { text = 'my-aa', var = 'AA' } } } }, + }, + final_tabstop, + }) + + validate('${1:aa${2:bb}cc$AA}$1', { + { + tabstop = '1', + placeholder = { { text = 'aa' }, { tabstop = '2', placeholder = { { text = 'bb' } } }, { text = 'cc' }, { text = 'my-aa', var = 'AA' } }, + }, + { + tabstop = '1', + placeholder = { { text = 'aa' }, { tabstop = '2', placeholder = { { text = 'bb' } } }, { text = 'cc' }, { text = 'my-aa', var = 'AA' } }, + }, + final_tabstop, + }) + + -- - Nesting same tabstop in placeholder is not allowed + expect.error(function() validate('${1:$1}') end, 'Placeholder can not contain its tabstop') + + -- - Should sync `choice` but preserve `transform` (for future) + validate('$1${1/.*//}${1|a,b|}', { + { tabstop = '1', placeholder = { { text = '' } } }, + { tabstop = '1', placeholder = { { text = '' } }, transform = { '.*', '', '' } }, + { tabstop = '1', placeholder = { { text = '' } } }, + final_tabstop, + }) + validate('${1|a,b|}${1/.*//}$1${1|c,d|}', { + { tabstop = '1', placeholder = { { text = 'a' } }, choices = { 'a', 'b' } }, + { tabstop = '1', placeholder = { { text = 'a' } }, choices = { 'a', 'b' }, transform = { '.*', '', '' } }, + { tabstop = '1', placeholder = { { text = 'a' } }, choices = { 'a', 'b' } }, + { tabstop = '1', placeholder = { { text = 'a' } }, choices = { 'a', 'b' } }, + final_tabstop, + }) + + -- - Should account for `lookup` resolution + eq( + parse('${1:aa}$1', { normalize = true, lookup = {['1'] = 'bb'} }), + { { tabstop = '1', text = 'bb' }, { tabstop = '1', text = 'bb' }, final_tabstop } + ) + + -- Should normalize however deep + validate('${BB:$1}', { { var = 'BB', placeholder = { { tabstop = '1', placeholder = { { text = '' } } } } }, final_tabstop }) + validate('${BB:${1:$CC}}', { { var = 'BB', placeholder = { { tabstop = '1', placeholder = { { var = 'CC', placeholder = { { text = '' } } } } } } }, final_tabstop }) + validate('${1:${BB:$CC}}', { { tabstop = '1', placeholder = { { var = 'BB', placeholder = { { var = 'CC', placeholder = { { text = '' } } } } } } }, final_tabstop }) + + validate('${1:${AA:$XX}}', { { tabstop = '1', placeholder = { { var = 'AA', text = 'my-aa' } } }, final_tabstop }) + validate('${1:${2:$AA}}', { { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { var = 'AA', text = 'my-aa' } } } } }, final_tabstop }) + + validate('${1:$0}', { { tabstop = '1', placeholder = { { tabstop = '0', placeholder = { { text = '' } } } } } }) + validate('${1:${0:text}}', { { tabstop = '1', placeholder = { { tabstop = '0', placeholder = { { text = 'text' } } } } } }) + + -- Evaluates variable only once + child.lua([[ + _G.log = {} + local os_getenv_orig = vim.loop.os_getenv + vim.loop.os_getenv = function(...) + table.insert(_G.log, { ... }) + return os_getenv_orig(...) + end + ]]) + validate( + '${AA}${AA}${BB}${BB}', + { + { var = 'AA', text = 'my-aa' }, { var = 'AA', text = 'my-aa' }, + { var = 'BB', placeholder = { { text = '' } } }, { var = 'BB', placeholder = { { text = '' } } }, + final_tabstop, + } + ) + eq(child.lua_get('_G.log'), { { 'AA' }, { 'BB' } }) + + -- - But not persistently + child.fn.setenv('AA', '!') + child.fn.setenv('BB', '?') + validate('${AA}${BB}', { { var = 'AA', text = '!' }, { var = 'BB', text = '?' }, final_tabstop }) +end + +--stylua: ignore +T['parse()']['respects `opts.lookup`'] = function() + local validate = function(snippet_body, lookup, ref_nodes) + eq(parse(snippet_body, { normalize = true, lookup = lookup }), ref_nodes) + end + local final_tabstop = { tabstop = '0', placeholder = { { text = '' } } } + + -- Can resolve variables from user lookup + validate('$BB', { BB = 'hello' }, { { var = 'BB', text = 'hello' }, final_tabstop }) + validate('$BB', { BB = 1 }, { { var = 'BB', text = '1' }, final_tabstop }) + + -- Should use only string fields + eq( + child.lua_get('MiniSnippets.parse("$true", { normalize = true, lookup = { [true] = "x" } })'), + { { var = 'true', placeholder = { { text = '' } } }, final_tabstop } + ) + validate('$1', { [1] = 'x' }, { { tabstop = '1', placeholder = { { text = '' } } }, final_tabstop }) + + -- - Should prefer user lookup + child.fn.setenv('AA', 'my-aa') + child.fn.setenv('XX', 'my-xx') + child.fn.setenv('EMPTY', '') + + validate('$AA', { AA = 'other' }, { { var = 'AA', text = 'other' }, final_tabstop }) + validate('$AA', { AA = '' }, { { var = 'AA', text = '' }, final_tabstop }) + validate('$EMPTY', { EMPTY = 'not empty' }, { { var = 'EMPTY', text = 'not empty' }, final_tabstop }) + + validate('$AA$XX', { AA = '!', XX = '?' }, { { var = 'AA', text = '!' }, { var = 'XX', text = '?' }, final_tabstop }) + + -- Can resolve tabstops from user lookup + validate('$1', { ['1'] = 'hello' }, { { tabstop = '1', text = 'hello' }, final_tabstop }) + validate('${1}', { ['1'] = 'hello' }, { { tabstop = '1', text = 'hello' }, final_tabstop }) + validate('${1:var}', { ['1'] = 'hello' }, { { tabstop = '1', text = 'hello' }, final_tabstop }) + + -- - Should resolve all tabstop entries + validate( + '$1$2$1', + { ['1'] = 'hello' }, + { + { tabstop = '1', text = 'hello' }, + { tabstop = '2', placeholder = { { text = '' } } }, + { tabstop = '1', text = 'hello' }, + final_tabstop, + } + ) + + validate('$0', { ['0'] = 'world' }, { { tabstop = '0', text = 'world' } }) + + -- - Should use tabstop as is + local lookup = { ['1'] = 'hello' } + local ref_nodes = { { tabstop = '01', placeholder = { { text = '' } } }, { tabstop = '1', text = 'hello' }, final_tabstop } + validate('${01}${1}', lookup, ref_nodes) + + -- - Should resolve on any depth + validate('${1:$2}', { ['2'] = 'xx' }, { { tabstop = '1', placeholder = { { tabstop = '2', text = 'xx' } } }, final_tabstop }) + validate('${1:${2:$3}}', { ['2'] = 'xx' }, { { tabstop = '1', placeholder = { { tabstop = '2', text = 'xx' } } }, final_tabstop }) + validate('${1:${2:$3}}', { ['3'] = 'xx' }, { { tabstop = '1', placeholder = { { tabstop = '2', placeholder = { { tabstop = '3', text = 'xx' } } } } }, final_tabstop }) + validate('${1:${2:$3}}', { ['2'] = 'xx', ['3'] = 'yy' }, { { tabstop = '1', placeholder = { { tabstop = '2', text = 'xx' } } }, final_tabstop }) +end + +--stylua: ignore +T['parse()']['can resolve special variables'] = function() + local validate = function(snippet_body, ref_nodes) eq(parse(snippet_body, { normalize = true }), ref_nodes) end + local final_tabstop = { tabstop = '0', placeholder = { { text = '' } } } + + local path = test_dir_absolute .. '/snippets/lua.json' + child.cmd('edit ' .. child.fn.fnameescape(path)) + set_lines({ 'abc def', 'ghi' }) + set_cursor(1, 1) + type_keys('yvj', '') + set_cursor(1, 2) + + -- Mock constant clipboard for better reproducibility of system registers + -- (mostly on CI). As `setreg('+', 'clip')` is not guaranteed to be working + -- for system clipboard, use `g:clipboard` which copies/pastes directly. + child.lua([[ + local clip = function() return { { 'clip' }, 'v' } end + local board = function() return { { 'board' }, 'v' } end + vim.g.clipboard = { + name = 'myClipboard', + copy = { ['+'] = clip, ['*'] = board }, + paste = { ['+'] = clip, ['*'] = board }, + } + ]]) + child.bo.commentstring = '/* %s */' + + -- LSP + validate('$TM_SELECTED_TEXT', { { var = 'TM_SELECTED_TEXT', text = 'bc def\ng' }, final_tabstop }) + validate('$TM_CURRENT_LINE', { { var = 'TM_CURRENT_LINE', text = 'abc def' }, final_tabstop }) + validate('$TM_CURRENT_WORD', { { var = 'TM_CURRENT_WORD', text = 'abc' }, final_tabstop }) + validate('$TM_LINE_INDEX', { { var = 'TM_LINE_INDEX', text = '0' }, final_tabstop }) + validate('$TM_LINE_NUMBER', { { var = 'TM_LINE_NUMBER', text = '1' }, final_tabstop }) + + local validate_path = function(var, ref_text) + local nodes = parse(var, { normalize = true }) + nodes[1].text = nodes[1].text:gsub('\\', '/') + eq(nodes, { { var = var:sub(2), text = ref_text }, final_tabstop }) + end + validate_path('$TM_FILENAME', 'lua.json') + validate_path('$TM_FILENAME_BASE', 'lua') + validate_path('$TM_DIRECTORY', test_dir_absolute .. '/snippets') + validate_path('$TM_FILEPATH', path) + + -- VS Code + validate_path('$RELATIVE_FILEPATH', test_dir .. '/snippets/lua.json') + validate_path('$WORKSPACE_FOLDER', child.fn.getcwd():gsub('\\', '/')) + validate('$CLIPBOARD', { { var = 'CLIPBOARD', text = 'clip' }, final_tabstop }) + validate('$CURSOR_INDEX', { { var = 'CURSOR_INDEX', text = '2' }, final_tabstop }) + validate('$CURSOR_NUMBER', { { var = 'CURSOR_NUMBER', text = '3' }, final_tabstop }) + validate('$LINE_COMMENT', { { var = 'LINE_COMMENT', text = '/*' }, final_tabstop }) + + -- - Date/time + child.lua([[ + _G.args_log = {} + vim.fn.strftime = function(...) + table.insert(_G.args_log, { ... }) + return 'datetime' + end + ]]) + local validate_datetime = function(var, ref_strftime_format) + child.lua('_G.args_log = {}') + validate(var, { { var = var:sub(2), text = 'datetime' }, final_tabstop }) + eq(child.lua_get('_G.args_log'), { { ref_strftime_format } }) + end + + validate_datetime('$CURRENT_YEAR', '%Y') + validate_datetime('$CURRENT_YEAR_SHORT', '%y') + validate_datetime('$CURRENT_MONTH', '%m') + validate_datetime('$CURRENT_MONTH_NAME', '%B') + validate_datetime('$CURRENT_MONTH_NAME_SHORT', '%b') + validate_datetime('$CURRENT_DATE', '%d') + validate_datetime('$CURRENT_DAY_NAME', '%A') + validate_datetime('$CURRENT_DAY_NAME_SHORT', '%a') + validate_datetime('$CURRENT_HOUR', '%H') + validate_datetime('$CURRENT_MINUTE', '%M') + validate_datetime('$CURRENT_SECOND', '%S') + validate_datetime('$CURRENT_TIMEZONE_OFFSET', '%z') + + child.lua('os.time = function() return 111 end') -- mock for more robust testing + validate('$CURRENT_SECONDS_UNIX', { { var = 'CURRENT_SECONDS_UNIX', text = '111' }, final_tabstop }) + + -- Random values + child.lua('vim.loop.hrtime = function() return 101 end') -- mock reproducible `math.randomseed` + local ref_random = { + { var = 'RANDOM', text = '491985' }, { var = 'RANDOM', text = '873024' }, + { var = 'RANDOM_HEX', text = '10347d' }, { var = 'RANDOM_HEX', text = 'df5ed0' }, + { var = 'UUID', text = '13d0871f-61d3-464a-b774-28645dca9e3a' }, { var = 'UUID', text = '7bac0382-1057-48d1-9f3b-9b45dbf681e8' }, + final_tabstop, + } + validate( '${RANDOM}${RANDOM}${RANDOM_HEX}${RANDOM_HEX}${UUID}${UUID}', ref_random) + + -- - Should prefer user lookup + eq( + parse('$TM_SELECTED_TEXT', { normalize = true, lookup = { TM_SELECTED_TEXT = 'xxx' } }), + { { var = 'TM_SELECTED_TEXT', text = 'xxx' }, final_tabstop } + ) + local random_opts = { normalize = true, lookup = { RANDOM = 'a', RANDOM_HEX = 'b', UUID = 'c' } } + local random_nodes = { + { var = 'RANDOM', text = 'a' }, { var = 'RANDOM', text = 'a' }, + { var = 'RANDOM_HEX', text = 'b' }, { var = 'RANDOM_HEX', text = 'b' }, + { var = 'UUID', text = 'c' }, { var = 'UUID', text = 'c' }, + final_tabstop, + } + eq(parse('${RANDOM}${RANDOM}${RANDOM_HEX}${RANDOM_HEX}${UUID}${UUID}', random_opts), random_nodes) + + -- Should evaluate variable only once + child.lua('_G.args_log = {}') + eq( + parse('${CURRENT_YEAR}${CURRENT_YEAR}${CURRENT_MONTH}${CURRENT_MONTH}', { normalize = true }), + { + { var = 'CURRENT_YEAR', text = 'datetime' }, { var = 'CURRENT_YEAR', text = 'datetime' }, + { var = 'CURRENT_MONTH', text = 'datetime' }, { var = 'CURRENT_MONTH', text = 'datetime' }, + final_tabstop, + } + ) + eq(child.lua_get('_G.args_log'), { { '%Y' }, { '%m' } }) +end + +T['parse()']['throws informative errors'] = function() + local validate = function(body, error_pattern) + expect.error(function() parse(body) end, error_pattern) + end + + -- Parsing + validate('${-', '${` should be followed by digit %(in tabstop%) or letter/underscore %(in variable%), not "%-"') + validate('${ ', '${` should be followed by digit %(in tabstop%) or letter/underscore %(in variable%), not " "') + + -- Tabstop + -- Should be closed with `}` + validate('${1', '"${" should be closed with "}"') + validate('${1a}', 'Tabstop id should be followed by "}", ":", "|", or "/" not "a"') + + -- Should be followed by either `:` or `}` + validate('${1 }', 'Tabstop id should be followed by "}", ":", "|", or "/" not " "') + validate('${1?}', 'Tabstop id should be followed by "}", ":", "|", or "/" not "?"') + validate('${1 |}', 'Tabstop id should be followed by "}", ":", "|", or "/" not " "') + + -- Choice + validate('${1|a', 'Tabstop with choices should be closed with "|}"') + validate('${1|a|', 'Tabstop with choices should be closed with "|}"') + validate('${1|a}', 'Tabstop with choices should be closed with "|}"') + validate([[${1|a\|}]], 'Tabstop with choices should be closed with "|}"') + validate('${1|a,b', 'Tabstop with choices should be closed with "|}"') + validate('${1|a,b}', 'Tabstop with choices should be closed with "|}"') + validate('${1|a,b|', 'Tabstop with choices should be closed with "|}"') + + validate('${1|a,b| $2', 'Tabstop with choices should be closed with "|}"') + validate('${1|a,b|,c}', 'Tabstop with choices should be closed with "|}"') + + -- Variable + validate('${a }', 'Variable name should be followed by "}", ":" or "/", not " "') + validate('${a?}', 'Variable name should be followed by "}", ":" or "/", not "?"') + validate('${a :}', 'Variable name should be followed by "}", ":" or "/", not " "') + validate('${a?:}', 'Variable name should be followed by "}", ":" or "/", not "?"') + + -- Placeholder + validate('${1:', 'Placeholder should be closed with "}"') + validate('${1:a', 'Placeholder should be closed with "}"') + validate('${1:a bb', 'Placeholder should be closed with "}"') + validate('${1:${2:a', 'Placeholder should be closed with "}"') + + -- - Nested nodes should error according to their rules + validate('${1:${2?}}', 'Tabstop id should be followed by "}", ":", "|", or "/" not "?"') + validate('${1:${2?', 'Tabstop id should be followed by "}", ":", "|", or "/" not "?"') + validate('${1:${2|a}}', 'Tabstop with choices should be closed with "|}"') + validate('${1:${a }}', 'Variable name should be followed by "}", ":" or "/", not " "') + validate('${1:${-}}', '${` should be followed by digit %(in tabstop%) or letter/underscore %(in variable%), not "%-"') + + -- Transform + validate([[${var/regex/format}]], 'Transform should contain 3 "/" outside of `${...}` and be closed with "}"') + validate( + [[${var/regex\/format/options}]], + 'Transform should contain 3 "/" outside of `${...}` and be closed with "}"' + ) + validate([[${var/.*/$\/i}]], 'Transform should contain 3 "/" outside of `${...}` and be closed with "}"') + validate('${var/regex/${/}options}', 'Transform should contain 3 "/" outside of `${...}` and be closed with "}"') + + validate([[${1/regex/format}]], 'Transform should contain 3 "/" outside of `${...}` and be closed with "}"') +end + +T['parse()']['validates input'] = function() + expect.error(function() parse(1) end, 'Snippet body.*string or array of strings') +end + +-- Integration tests ========================================================== +T['Session'] = new_set() + +local start_session = function(snippet) default_insert({ body = snippet }) end + +T['Session']['cleans extmarks when they are not needed'] = function() + local ns_id + local validate_n_extmarks = function(ref_n) + local session = get() + if session ~= vim.NIL then ns_id = session.ns_id end + local all_extmarks = child.api.nvim_buf_get_extmarks(0, ns_id, 0, -1, {}) + eq(#all_extmarks, ref_n) + end + + start_session('T1=${1:<$2>}') + validate_n_extmarks(9) + + type_keys('x') + validate_state('i', { 'T1=x' }, { 1, 4 }) + validate_n_extmarks(5) + + start_session('U1=$1') + validate_n_extmarks(11) + + stop() + validate_n_extmarks(5) + stop() + validate_n_extmarks(0) +end + +T['Session']['persists after `:edit`'] = function() + local path = test_dir_absolute .. '/tmp' + child.fn.writefile({}, path) + MiniTest.finally(function() child.fn.delete(path) end) + edit(path) + + start_session('T1=$1 T0=$0') + validate_active_session() + + -- NOTE: Write changes as making `:edit!` work is unreasonable + child.cmd('write') + child.cmd('edit') + sleep(small_time) + + -- Should preserve both highlighting and data + validate_active_session() + child.expect_screenshot() +end + +T['Session']['should replace placeholder on added text at its start'] = function() + start_session('T1=${1:aaa} T0=$0') + type_keys('x') + validate_state('i', { 'T1=x T0=' }, { 1, 4 }) + ensure_clean_state() + + -- No replace on adding text not at start + start_session('T1=${1:aaa} T0=$0') + type_keys('', 'x') + validate_state('i', { 'T1=axaa T0=' }, { 1, 5 }) + + -- - But should still track placeholder range to properly delete later + type_keys('', '', 'y') + validate_state('i', { 'T1=y T0=' }, { 1, 4 }) + ensure_clean_state() + + -- Should be the same if text is added in Normal mode + start_session('T1=${1:aaa} T0=$0') + type_keys('', 'yl', 'p') + validate_state('n', { 'T1== T0=' }, { 1, 3 }) + ensure_clean_state() + + start_session('T1=${1:aaa} T0=$0') + type_keys('', '', 'P') + validate_state('n', { 'T1=a=aa T0=' }, { 1, 4 }) + type_keys('', 'P') + validate_state('n', { 'T1== T0=' }, { 1, 3 }) +end + +T['Session']['preserves order of "squashed" empty tabstops'] = function() + start_session('$1$2$3 $1$3$2 $2$1$3 $2$3$1 $3$1$2 $3$2$1') + child.expect_screenshot() + jump('next') + child.expect_screenshot() + jump('next') + child.expect_screenshot() + ensure_clean_state() + + start_session('$1$2$0') + jump('next') + child.expect_screenshot() + jump('next') + child.expect_screenshot() +end + +T['Session']['whole session tracking'] = function() + local validate_session_range = function(ref_from, ref_to) + local session = get() + local data = + child.api.nvim_buf_get_extmark_by_id(session.buf_id, session.ns_id, session.extmark_id, { details = true }) + eq({ data[1], data[2], data[3].end_row, data[3].end_col }, { ref_from[1], ref_from[2], ref_to[1], ref_to[2] }) + end + + type_keys('i', '----', '') + start_session('T1=${1:aa}_T0=$0') + validate_session_range({ 0, 2 }, { 0, 11 }) + + -- Typing text inside tabstop should be tracked + type_keys('x') + validate_session_range({ 0, 2 }, { 0, 10 }) + type_keys('') + validate_session_range({ 0, 2 }, { 1, 4 }) + + -- Modifying text outside of intended snippet session should also be tracked + -- with "expanding" extmark (right_gravity=false end_right_gravity=true) + type_keys('') + -- - Adding text strictly to the left should move session range + type_keys('gg0', 'i', 'new') + validate_state('i', { 'new--T1=x', '_T0=--' }, { 1, 3 }) + validate_session_range({ 0, 5 }, { 1, 4 }) + -- - Adding text at left edge should count as "in session range" + type_keys('', 'wow') + validate_state('i', { 'new--wowT1=x', '_T0=--' }, { 1, 8 }) + validate_session_range({ 0, 5 }, { 1, 4 }) + -- - Adding text at right edge should count as "in session range" + -- NOTE: typing text at $0 doesn't stop session as $0 is not current + type_keys('', 'huh') + validate_state('i', { 'new--wowT1=x', '_T0=huh--' }, { 2, 7 }) + validate_session_range({ 0, 5 }, { 1, 7 }) + -- - Adding text past right edge should not touch session + type_keys('', 'no') + validate_state('i', { 'new--wowT1=x', '_T0=huh-no-' }, { 2, 10 }) + validate_session_range({ 0, 5 }, { 1, 7 }) +end + +T['Session']['autostop'] = new_set() + +T['Session']['autostop']['works when text is typed with final tabstop being current'] = function() + local validate = function(key) + start_session('T1=$1 T0=$0') + validate_active_session() + jump('next') + type_keys(key) + validate_no_active_session() + ensure_clean_state() + end + + -- Adding visible character + validate('x') + validate(' ') + validate('\t') + + -- Adding invisible character + validate('') + + -- Deleting + validate('') + validate('') + + -- Making text not in pure Insert mode + validate('o') + validate('guu') +end + +T['Session']['autostop']['works when exiting to Normal mode in final tabstop'] = function() + start_session('T1=$1 T0=$0') + validate_active_session() + jump('next') + type_keys('') + validate_no_active_session() + ensure_clean_state() + + -- Should stop only when exiting in full Normal mode + start_session('T1=$1 T0=$0') + jump('next') + type_keys('') + validate_active_session() +end + +T['Session']['autostop']['works when final tabstop has explicit placeholder'] = function() + -- Typing should remove placeholder and keep Insert mode + start_session('T1=$1 T0=${0:aaa}') + jump('next') + validate_state('i', { 'T1= T0=aaa' }, { 1, 7 }) + + type_keys('x') + validate_no_active_session() + validate_state('i', { 'T1= T0=x' }, { 1, 8 }) + + ensure_clean_state() + + -- Exiting in Normal mode should preserve placeholder + start_session('T1=$1 T0=${0:aaa}') + jump('next') + type_keys('') + validate_no_active_session() + validate_state('n', { 'T1= T0=aaa' }, { 1, 6 }) +end + +T['Session']['autostop']['is not triggered if final tabstop is not current'] = function() + start_session('T1=$1 T0=$0') + validate_active_session() + + -- Exiting into Normal mode should still keep session active + type_keys('') + validate_active_session() + + -- Typing at final tabstop should not autostop because it is not current + type_keys('A', 'new') + validate_active_session() + child.expect_screenshot() + + -- Should still be possible to autostop even though final tabstop is moved + jump('next') + type_keys('x') + validate_no_active_session() +end + +T['Session']['highlighting'] = new_set() + +local validate_tabstop_hl = function(ref_extmark_data, session) + session = session or get() + local buf_id, ns_id = session.buf_id, session.ns_id + local has_inline_extmarks = child.fn.has('nvim-0.10') == 1 + + local out = {} + local record_tabstop_extmark + record_tabstop_extmark = function(n_arr) + for _, n in ipairs(n_arr) do + if n.tabstop ~= nil then + local data = child.api.nvim_buf_get_extmark_by_id(buf_id, ns_id, n.extmark_id, { details = true }) + local t = { + tabstop = n.tabstop, + hl_group = data[3].hl_group, + virt_text = data[3].virt_text, + virt_text_pos = data[3].virt_text_pos, + } + table.insert(out, t) + end + if n.placeholder ~= nil then record_tabstop_extmark(n.placeholder) end + end + end + record_tabstop_extmark(session.nodes) + + if not has_inline_extmarks then + ref_extmark_data = vim.tbl_map(function(x) + x.virt_text, x.virt_text_pos = nil, nil + return x + end, vim.deepcopy(ref_extmark_data)) + end + + eq(out, ref_extmark_data) +end + +T['Session']['highlighting']['updates current/visited/unvisited/final'] = function() + start_session('T1=${1:aa} T2=$2 T3=${3:cc} T0=$0') + local ref_extmark_data = { + -- Initial tabstop should be "*Current*", not "*Visited" + { tabstop = '1', hl_group = 'MiniSnippetsCurrentReplace' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + { tabstop = '3', hl_group = 'MiniSnippetsUnvisited' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + -- Changing focused tabstop should update highlight groups accordingly + jump('next') + ref_extmark_data = { + -- Already visited are marked as "*Visited" + { tabstop = '1', hl_group = 'MiniSnippetsVisited' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '3', hl_group = 'MiniSnippetsUnvisited' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + -- Revisiting back should again mark as current but keep "visited" for others + jump('prev') + ref_extmark_data = { + -- Revisiting should not make a difference for current tabstop + { tabstop = '1', hl_group = 'MiniSnippetsCurrentReplace' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '3', hl_group = 'MiniSnippetsUnvisited' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + -- Jumping left should properly not mark skipped tabstops as visited + jump('prev') + ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsVisited' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '3', hl_group = 'MiniSnippetsUnvisited' }, + -- Current final is marked as "*CurrentReplace" as there is a placeholder + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) +end + +T['Session']['highlighting']['updates after replacing placeholder'] = function() + start_session('T1=$1 T1=${1:aa}') + local ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + -- Should switch to "*Current" as there is no replacing + type_keys('x') + ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsCurrent' }, + { tabstop = '1', hl_group = 'MiniSnippetsCurrent' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + -- Going back should still use "*Current" as there is still no replacing + jump('next') + jump('prev') + ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsCurrent' }, + { tabstop = '1', hl_group = 'MiniSnippetsCurrent' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) +end + +--stylua: ignore +T['Session']['highlighting']['uses same highlight groups for linked tabstops'] = function() + start_session('T1=$1 T1=${1:aa} T1=${1|bb,cc|} T2=$2 T2=${2:dd}') + local ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + -- All are empty as they are normalized to the same placeholder/text + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + jump('next') + ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) +end + +T['Session']['highlighting']['properly highlights final tabstop'] = function() + -- Should still be highlighted as "*CurrentReplace" if automatically added + start_session('T1=$1') + jump('next') + local ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + ensure_clean_state() + + -- Should highlight with explicit placeholder + start_session('T1=$1 T0=${0:aa}') + jump('next') + ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '0', hl_group = 'MiniSnippetsCurrentReplace' }, + } + validate_tabstop_hl(ref_extmark_data) + ensure_clean_state() + + -- Should never use "visited"/"unvisited" groups + start_session('T1=$1 T0=$0') + jump('next') + jump('prev') + ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + ensure_clean_state() + + -- Works with linked final tabstops (although this snippet makes small sense) + start_session('T1=$1 T0=$0 T0=$0') + ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + jump('next') + ref_extmark_data = { + { tabstop = '1', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + ensure_clean_state() + + -- Should treat strictly only $0 as final + start_session('$00 $0') + ref_extmark_data = { + { tabstop = '00', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) +end + +T['Session']['highlighting']['uses same highlighting for whole placeholder for current tabstop'] = function() + start_session('T1=${1:} $2 $0 $3') + local ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsCurrentReplace' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + child.expect_screenshot() + + jump('next') + ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsVisited' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsUnvisited' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + child.expect_screenshot() + + jump('next') + ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsVisited' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '2', virt_text = { { '•', 'MiniSnippetsVisited' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + { tabstop = '3', virt_text = { { '•', 'MiniSnippetsCurrentReplace' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + child.expect_screenshot() +end + +T['Session']['highlighting']['hides when nesting'] = function() + start_session('T1=${1:aa} T0=$0') + local prev_session = get() + local ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsCurrentReplace' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data) + + start_session('U1=${1:aa} U0=$0') + local cur_session = get() + + -- No highlighting attributes should be set in previous session + validate_tabstop_hl({ { tabstop = '1' }, { tabstop = '0' } }, prev_session) + -- - Current session should be highlighted + ref_extmark_data = { + { tabstop = '1', hl_group = 'MiniSnippetsCurrentReplace' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data, cur_session) + + stop() + ref_extmark_data = { + -- Highlight group changed '*CurrentReplace' -> '*Current' as there was + -- text change (nested session text) at the start of tabstop's placeholder + { tabstop = '1', hl_group = 'MiniSnippetsCurrent' }, + { tabstop = '0', virt_text = { { '∎', 'MiniSnippetsFinal' } }, virt_text_pos = 'inline' }, + } + validate_tabstop_hl(ref_extmark_data, prev_session) +end + +T['Session']['choices'] = new_set() + +T['Session']['choices']['works'] = function() + start_session('T1=${1|aa,bb|} T2=${2|dd,cc|}') + child.expect_screenshot() + + -- Should show first choice as placeholder (not as text) + eq_partial_tbl(get().nodes[2], { tabstop = '1', placeholder = { { text = 'aa' } }, choices = { 'aa', 'bb' } }) + eq_partial_tbl(get().nodes[4], { tabstop = '2', placeholder = { { text = 'dd' } }, choices = { 'dd', 'cc' } }) + + -- Should show choices initially + validate_pumitems({ 'aa', 'bb' }) + + -- Initial select should replace placeholder with first choice + validate_state('i', { 'T1=aa T2=dd' }, { 1, 3 }) + type_keys('') + eq_partial_tbl(get().nodes[2], { tabstop = '1', text = 'aa', choices = { 'aa', 'bb' } }) + eq(get().nodes[2].placeholder, nil) + validate_state('i', { 'T1=aa T2=dd' }, { 1, 5 }) + + -- Removing text back to empty text should reshow all choices + type_keys('', '') + validate_no_pumvisible() + type_keys('') + validate_pumitems({ 'aa', 'bb' }) + -- - Should still show inline virtual text + child.expect_screenshot() +end + +T['Session']['choices']['are shown only when needed'] = function() + start_session('T1=${1|aa,bb|} T2=${2|dd,cc|}') + + -- Initially + validate_pumitems({ 'aa', 'bb' }) + + -- After jumps + jump('next') + validate_pumitems({ 'dd', 'cc' }) + + -- Not when editing non-empty text (to not conflict with autocompletion) + type_keys('d', 'x') + validate_no_pumvisible() + type_keys('') + validate_no_pumvisible() +end + +T['Session']['choices']['are relevant to text'] = function() + default_insert({ body = 'T1=${1|aa,bb|} T2=${2|dd,cc|}' }, { lookup = { ['2'] = 'd' } }) + validate_state('i', { 'T1=aa T2=d' }, { 1, 3 }) + + -- Reference node has placeholder -> all choices + -- - Even immediately after start + validate_pumitems({ 'aa', 'bb' }) + jump('next') + jump('prev') + validate_pumitems({ 'aa', 'bb' }) + + -- Reference node has text -> choices matching text + -- - Even if text is "forced" via lookup + jump('next') + validate_pumitems({ 'dd' }) + child.expect_screenshot() + + type_keys('', 'c') + jump('prev') + jump('next') + validate_pumitems({ 'cc' }) + child.expect_screenshot() +end + +T['Session']['choices']["respects 'completeopt' when computing choices"] = function() + start_session('T1=${1|ba,aa,xx|}') + type_keys('a') + jump('next') + + -- With no 'fuzzy' should match by prefix start + child.go.completeopt = 'menuone,noselect' + jump('prev') + validate_pumitems({ 'aa' }) + + -- With 'fuzzy' should match fuzzy. Should also prefer buffer-local value. + if child.fn.has('nvim-0.11') == 0 then MiniTest.skip('Fuzzy matching is available only on Neovim>=0.11') end + jump('next') + child.bo.completeopt = 'menuone,noselect,fuzzy' + jump('prev') + validate_pumitems({ 'aa', 'ba' }) + + -- - Should match all items with empty text + type_keys('') + validate_pumitems({ 'ba', 'aa', 'xx' }) + + jump('next') + jump('prev') + validate_pumitems({ 'ba', 'aa', 'xx' }) +end + +T['Session']['choices']["work with default 'completeopt'"] = function() + child.o.completeopt = 'menu,preview' + start_session('T1=${1|aa,bb|} T2=${2|dd,cc|}') + child.expect_screenshot() + + -- As there is no 'noselect', first choice is selected immediately + -- and thus replaces placeholder + eq_partial_tbl(get().nodes[2], { tabstop = '1', text = 'aa', choices = { 'aa', 'bb' } }) + + -- Showing choices at empty text automatically selects first item + type_keys('') + type_keys('') + child.expect_screenshot() +end + +T['Session']['choices']['selecting completion item properly replaces current text'] = function() + if child.fn.has('nvim-0.11') == 0 then MiniTest.skip('Fuzzy matching is available only on Neovim>=0.11') end + child.o.completeopt = 'menuone,noselect,fuzzy' + start_session('T1=${1|axax,yy|}') + type_keys('xx') + jump('next') + jump('prev') + + validate_pumitems({ 'axax' }) + type_keys('') + validate_state('i', { 'T1=axax' }, { 1, 7 }) +end + +T['Session']['choices']['handles linked tabstops with different choices'] = function() + -- Should resolve all initial text to be the same while removing choices from + -- the repeated nodes (as redundant) + start_session('T1=${1|aa,bb|} T1=${1|uu,vv|}') + validate_active_session() + validate_state('i', { 'T1=aa T1=aa' }, { 1, 3 }) + -- Should only use choices from reference node + validate_pumitems({ 'aa', 'bb' }) + jump('next') + eq(get_cur_tabstop(), '0') +end + +T['Session']['linked tabstops'] = new_set() + +T['Session']['linked tabstops']['are updated immediately when typing'] = function() + start_session('T1=$1_T1=$1') + type_keys('a') + validate_state('i', { 'T1=a_T1=a' }, { 1, 4 }) + + -- Even after jumping back + jump('next') + jump('prev') + type_keys('b') + validate_state('i', { 'T1=ab_T1=ab' }, { 1, 5 }) + child.expect_screenshot() + + -- Even multiline + if child.fn.has('nvim-0.10') == 0 then MiniTest.skip('Multiline text sync has issues with cursor on Neovim<0.10') end + type_keys('') + validate_state('i', { 'T1=ab', '_T1=ab', '' }, { 2, 0 }) + child.expect_screenshot() + type_keys('c') + validate_state('i', { 'T1=ab', 'c_T1=ab', 'c' }, { 2, 1 }) + child.expect_screenshot() +end + +T['Session']['linked tabstops']['are updated immediately when deleting text'] = function() + start_session('$1\n$1') + type_keys('a bcd') + validate_state('i', { 'a bcd', 'a bcd' }, { 1, 5 }) + + type_keys('') + validate_state('i', { 'a bc', 'a bc' }, { 1, 4 }) + + type_keys('') + validate_state('i', { 'a ', 'a ' }, { 1, 2 }) + + type_keys('') + validate_state('i', { '', '' }, { 1, 0 }) +end + +T['Session']['linked tabstops']['are updated on text change in Normal mode'] = function() + start_session('$1\n$1') + type_keys('ab cd') + validate_state('i', { 'ab cd', 'ab cd' }, { 1, 5 }) + + type_keys('', 'daw') + validate_state('n', { 'ab', 'ab' }, { 1, 1 }) + + type_keys('0', 'P') + validate_state('n', { ' cdab', ' cdab' }, { 1, 2 }) +end + +T['Session']['linked tabstops']['are updated when completion popup is visible'] = function() + start_session('aa bb $1 $1') + type_keys('') + validate_pumitems({ 'aa', 'bb' }) + validate_state('i', { 'aa bb ' }, { 1, 6 }) + + type_keys('a') + validate_state('i', { 'aa bb a a' }, { 1, 7 }) + + type_keys('') + validate_state('i', { 'aa bb aa aa' }, { 1, 8 }) +end + +T['Session']['linked tabstops']['delay updating in nested session until stop'] = function() + start_session('T1=$1 T1=$1') + validate_state('i', { 'T1= T1=' }, { 1, 3 }) + + start_session('U1=$1 U1=$1') + -- Update right after start to remove placeholder from current session + validate_state('i', { 'T1=U1= U1= T1=U1= U1=' }, { 1, 6 }) + + -- No update on second `$1` from previous session + validate_state('i', { 'T1=U1= U1= T1=U1= U1=' }, { 1, 6 }) + + -- Should work with deeper nesting + start_session('$1') + type_keys('x') + validate_state('i', { 'T1=U1=x U1= T1=U1= U1=' }, { 1, 7 }) + + -- Should linked tabstops in previous after stopping current + stop() + validate_state('i', { 'T1=U1=x U1=x T1=U1= U1=' }, { 1, 7 }) + + stop() + validate_state('i', { 'T1=U1=x U1=x T1=U1=x U1=x' }, { 1, 7 }) +end + +T['Session']['linked tabstops']['works for tabstops with different placeholders'] = function() + -- Should be resolved to have same placeholder during `parse()` + start_session('T1=${1:aa} T1=${1:bb} T1=$1') + validate_state('i', { 'T1=aa T1=aa T1=aa' }, { 1, 3 }) + type_keys('x') + validate_state('i', { 'T1=x T1=x T1=x' }, { 1, 4 }) + ensure_clean_state() + + -- Even if have different initial types + start_session('T1=$1 T1=${1:aa} T1=${1|bb,cc|}') + validate_state('i', { 'T1= T1= T1=' }, { 1, 3 }) + type_keys('x') + validate_state('i', { 'T1=x T1=x T1=x' }, { 1, 4 }) +end + +T['Session']['linked tabstops']['jumps to the first node'] = function() + start_session('T1=${1:} T2=$2 T1=$1') + validate_state('i', { 'T1= T2= T1=' }, { 1, 3 }) + child.expect_screenshot() + + jump('next') + validate_state('i', { 'T1= T2= T1=' }, { 1, 7 }) + child.expect_screenshot() + + -- Even if first node for linked tabstops is changed + jump('prev') + type_keys('x') + validate_state('i', { 'T1=x T2= T1=x' }, { 1, 4 }) + jump('next') + validate_state('i', { 'T1=x T2= T1=x' }, { 1, 8 }) + child.expect_screenshot() +end + +T['Session']['linked tabstops']['validates that session data is valid'] = function() + local ref_msg = '(mini.snippets) Session contains corrupted data (deleted or out of range extmarks). It is stopped.' + + -- Forcefully removed extmarks + start_session('T1=$1\nT0=$0') + child.api.nvim_buf_clear_namespace(0, get().ns_id, 0, -1) + validate_active_session() + + type_keys('x') + validate_no_active_session() + eq(child.lua_get('_G.notify_log'), { { ref_msg, 'WARN' } }) + child.lua('_G.notify_log = {}') + child.expect_screenshot() + + ensure_clean_state() + + -- Out of range extmarks + start_session('T1=$1\nT0=$0') + type_keys('', 'j', 'dd') + validate_no_active_session() + eq(child.lua_get('_G.notify_log'), { { ref_msg, 'WARN' } }) + child.expect_screenshot() +end + +T['Session']['linked tabstops']['handle text change in not reference node'] = function() + start_session('T1=${1:aa} T1=${1:aa} T1=${1:aa}') + validate_state('i', { 'T1=aa T1=aa T1=aa' }, { 1, 3 }) + + -- Any text change is allowed if tabstops are still in "replace" stage + set_cursor(1, 10) + type_keys('x') + validate_state('i', { 'T1=aa T1=axa T1=aa' }, { 1, 11 }) + + -- Should still track changes and replace appropriately + jump('next') + jump('prev') + type_keys('yy') + validate_state('i', { 'T1=yy T1=yy T1=yy' }, { 1, 5 }) + + -- After placeholder is replaced, all linked tabstops should be forced to + -- have same text as the first (reference) node + set_cursor(1, 10) + type_keys('A') + validate_state('i', { 'T1=yy T1=yy T1=yy' }, { 1, 11 }) + + set_cursor(1, 16) + type_keys('B') + validate_state('i', { 'T1=yy T1=yy T1=yy' }, { 1, 17 }) +end + +T['Session']['nesting'] = new_set({ hooks = { pre_case = setup_event_log } }) + +T['Session']['nesting']['works and triggers events'] = function() + local body_1, body_2, body_3 = 'T1=$1 T0=$0', 'U1=$1 U0=$0', 'V1=$1 V0=$0' + + start_session(body_1) + validate_n_sessions(1) + eq(get_snippet_body(get()), body_1) + child.expect_screenshot() + + start_session(body_2) + eq(get_snippet_body(get()), body_2) + validate_n_sessions(2) + -- Highlighting of previous session should stop, but should still track + child.expect_screenshot() + + start_session(body_3) + -- Any user typing in nested session should be tracked in all other sessions + type_keys('vvv') + eq(get_snippet_body(get()), body_3) + validate_n_sessions(3) + child.expect_screenshot() + + -- Jumping inside nested session should be done only within its tabstops + jump('next') + eq(get_cursor(), { 1, 16 }) + -- - Along with wrapping + jump('next') + eq(get_cursor(), { 1, 12 }) + + stop() + eq(get_snippet_body(get()), body_2) + validate_n_sessions(2) + -- Tabstop range of previous session should track changes in nested ones + child.expect_screenshot() + + stop() + validate_n_sessions(1) + eq(get_snippet_body(get()), body_1) + child.expect_screenshot() + + stop() + + -- Should trigger proper events in proper order + local make_ref_data = function(snippet_body) + return { session = { insert_args = { snippet = { body = snippet_body } } } } + end + local cur_buf_id = get_buf() + --stylua: ignore + local ref_au_log_partial = { + { event = 'MiniSnippetsSessionStart', data = make_ref_data(body_1), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionSuspend', data = make_ref_data(body_1), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionStart', data = make_ref_data(body_2), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionSuspend', data = make_ref_data(body_2), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionStart', data = make_ref_data(body_3), buf_id = cur_buf_id }, + + { event = 'MiniSnippetsSessionJumpPre', data = { tabstop_from = '1', tabstop_to = '0' }, buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionJump', data = { tabstop_from = '1', tabstop_to = '0' }, buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionJumpPre', data = { tabstop_from = '0', tabstop_to = '1' }, buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionJump', data = { tabstop_from = '0', tabstop_to = '1' }, buf_id = cur_buf_id }, + + { event = 'MiniSnippetsSessionStop', data = make_ref_data(body_3), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionResume', data = make_ref_data(body_2), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionStop', data = make_ref_data(body_2), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionResume', data = make_ref_data(body_1), buf_id = cur_buf_id }, + { event = 'MiniSnippetsSessionStop', data = make_ref_data(body_1), buf_id = cur_buf_id }, + } + eq_partial_tbl(get_au_log(), ref_au_log_partial) +end + +T['Session']['nesting']['does not nest if no tabstops in new session'] = function() + start_session('T1=$1 T0=$0') + start_session('just text') + validate_n_sessions(1) + child.expect_screenshot() +end + +T['Session']['nesting']['resuming session should not change mode/cursor/buffer'] = function() + -- Resuming in current buffer + start_session('T1=$1\nT0=$0\n') + type_keys('') + start_session('U1=$1 U0=$0') + jump('next') + + validate_state('i', { 'T1=', 'T0=', 'U1= U0=' }, { 3, 7 }) + validate_n_sessions(2) + type_keys('x') + validate_state('i', { 'T1=', 'T0=', 'U1= U0=x' }, { 3, 8 }) + validate_n_sessions(1) + eq(get_snippet_body(), 'T1=$1\nT0=$0\n') + + ensure_clean_state() + + -- Resuming in another buffer + start_session('T1=$1 T0=$0') + child.ensure_normal_mode() + local new_buf_id = new_buf() + set_buf(new_buf_id) + start_session('U1=$1 U0=$0') + + jump('next') + eq(child.fn.mode(), 'i') + eq(get_cur_tabstop(), '0') + type_keys('') + -- Should not change mode or buffer + eq(child.fn.mode(), 'n') + eq(get_buf(), new_buf_id) +end + +T['Session']['nesting']['can be done outside of current session region'] = function() + start_session('T1=$1 T0=$0') + type_keys('', 'o', '') + start_session('U1=$1 U0=$0') + validate_n_sessions(2) + child.expect_screenshot() +end + +T['Session']['nesting']['can be done in different buffer'] = function() + start_session('T1=$1 T0=$0') + child.ensure_normal_mode() + local prev_buf_id, new_buf_id = get_buf(), new_buf() + set_buf(new_buf_id) + start_session('U1=$1 U0=$0') + + validate_n_sessions(2) + eq(get_buf(), new_buf_id) + eq_partial_tbl(get(), { buf_id = new_buf_id, insert_args = { snippet = { body = 'U1=$1 U0=$0' } } }) + + -- Stopping session should not change buffer or jump + stop() + eq_partial_tbl(get(), { buf_id = prev_buf_id, insert_args = { snippet = { body = 'T1=$1 T0=$0' } } }) + eq(get_buf(), new_buf_id) + validate_state('i', { 'U1= U0=' }, { 1, 3 }) +end + +T['Session']['nesting']['session stack is properly cleaned when buffer is unloaded'] = function() + local buf_id_1, buf_id_2, buf_id_3 = new_buf(), new_buf(), new_buf() + local body_1, body_2, body_3, body_4 = 'T1=$1 T0=$0', 'U1=$1 U0=$0', 'V1=$1 V0=$0', 'W1=$1 W0=$0' + set_buf(buf_id_1) + start_session(body_1) + set_buf(buf_id_2) + start_session(body_2) + set_buf(buf_id_3) + start_session(body_3) + start_session(body_4) + + local ref_sessions = { + { buf_id = buf_id_1, insert_args = { snippet = { body = body_1 } } }, + { buf_id = buf_id_2, insert_args = { snippet = { body = body_2 } } }, + { buf_id = buf_id_3, insert_args = { snippet = { body = body_3 } } }, + { buf_id = buf_id_3, insert_args = { snippet = { body = body_4 } } }, + } + eq_partial_tbl(get_all(), ref_sessions) + + clean_au_log() + + -- Deleting session in the middle of stack should work + child.api.nvim_buf_delete(buf_id_2, { force = true, unload = true }) + eq_partial_tbl(get_all(), { ref_sessions[1], ref_sessions[3], ref_sessions[4] }) + + -- Deleting current session should make the nearest one active + child.api.nvim_buf_delete(buf_id_3, { force = true, unload = true }) + eq_partial_tbl(get_all(), { ref_sessions[1] }) + + -- Deleting the last active session should also work + eq(get_buf(), buf_id_1) + child.api.nvim_buf_delete(buf_id_1, { force = true, unload = false }) + validate_n_sessions(0) + + -- Proper events should still be triggered during session clean + local make_ref_data = function(buf_id, snippet_body) + return { session = { buf_id = buf_id, insert_args = { snippet = { body = snippet_body } } } } + end + --stylua: ignore + local ref_au_log_partial = { + -- Event can be triggered with other buffer being current (due to + -- `vim.schedule_wrap()` needed to make `:edit` work) + { event = 'MiniSnippetsSessionStop', data = make_ref_data(buf_id_2, body_2), buf_id = buf_id_3 }, + -- No 'Resume' of already active session + -- Unloading current buffer should also be possible + { event = 'MiniSnippetsSessionStop', data = make_ref_data(buf_id_3, body_3), buf_id = buf_id_1 }, + { event = 'MiniSnippetsSessionStop', data = make_ref_data(buf_id_3, body_4), buf_id = buf_id_1 }, + -- Deleting active session resumes the next available + { event = 'MiniSnippetsSessionResume', data = make_ref_data(buf_id_1, body_1), buf_id = buf_id_1 }, + { event = 'MiniSnippetsSessionStop', data = make_ref_data(buf_id_1, body_1), buf_id = get_buf() }, + } + eq_partial_tbl(get_au_log(), ref_au_log_partial) +end + +T['Interaction with built-in completion'] = new_set() + +T['Interaction with built-in completion']['popup removal during insert'] = function() + set_lines({ 'abc', '' }) + set_cursor(2, 0) + + type_keys('i', '') + validate_pumvisible() + default_insert({ body = 'no tabstops' }) + validate_no_pumvisible() + validate_no_active_session() + + type_keys('', '') + validate_pumvisible() + default_insert({ body = 'yes tabstops: $1' }) + validate_no_pumvisible() + validate_active_session() +end + +T['Interaction with built-in completion']['popup removal during jump'] = function() + default_insert({ body = 'abc $1 $2' }) + type_keys('a', '') + validate_pumvisible() + jump('next') + validate_no_pumvisible() + + type_keys('a', '') + validate_pumvisible() + jump('prev') + validate_no_pumvisible() +end + +T['Interaction with built-in completion']['preserves popup on autoclose'] = function() + default_insert({ body = 'abc $1 $0' }) + jump('next') + type_keys('') + validate_pumvisible() + + type_keys('a') + sleep(small_time) + validate_no_active_session() + validate_pumvisible() +end + +T['Interaction with built-in completion']['no affect of "exausted" popup during jump'] = function() + default_insert({ body = 'abc $1 $2' }) + type_keys('a', '', 'x') + validate_no_pumvisible() + jump('next') + + type_keys('x') + child.expect_screenshot() +end + +T['Interaction with built-in completion']['no wrong automatic session stop during jump'] = function() + default_insert({ body = 'ab $1\n$1\n$0' }) + type_keys('a', '') + validate_pumvisible() + jump('next') + sleep(small_time) + validate_active_session() +end + +T['Interaction with built-in completion']['squeezed tabstops'] = function() + default_insert({ body = '$1$2$1$2$1' }) + type_keys('abc', '', 'x') + type_keys('') + child.expect_screenshot() + type_keys('y') + -- NOTE: Requires the fix for extmarks to not be affected + -- See https://github.com/neovim/neovim/issues/31384 + if child.fn.has('nvim-0.10.3') == 1 then child.expect_screenshot() end +end + +T['Interaction with built-in completion']['cycling through candidates'] = function() + set_lines({ 'aa bb', '' }) + set_cursor(2, 0) + default_insert({ body = '$1$1' }) + type_keys('', '') + validate_state('i', { 'aa bb', 'aaaa' }, { 2, 2 }) + validate_pumvisible() + + type_keys('') + -- NOTE: Requires the fix for extmarks to not be affected + -- See https://github.com/neovim/neovim/pull/31475 + if child.fn.has('nvim-0.10.3') == 1 then validate_state('i', { 'aa bb', '' }, { 2, 0 }) end + validate_pumvisible() +end + +T['Various snippets'] = new_set() + +T['Various snippets']['text'] = function() + local validate = function(snippet_body, ref_lines, ref_cursor) + start_session(snippet_body) + validate_no_active_session() + validate_state('i', ref_lines, ref_cursor) + ensure_clean_state() + end + + -- Basic cases + validate('Hello world', { 'Hello world' }, { 1, 11 }) + validate('Hello\nmultiline \nworld', { 'Hello', 'multiline ', 'world' }, { 3, 5 }) + + type_keys('i', ' \t') + validate('Hello\nmultiline \nworld', { ' \tHello', ' \tmultiline ', ' \tworld' }, { 3, 7 }) + + -- Single present `$0` should be treated as "just text" + validate('Hello world$0', { 'Hello world' }, { 1, 11 }) + validate('Hello $0 world', { 'Hello world' }, { 1, 6 }) + validate('Hello\n $0\nworld', { 'Hello', ' ', 'world' }, { 2, 2 }) +end + +T['Various snippets']['var'] = function() + local validate = function(snippet_body, ref_lines, ref_cursor) + start_session(snippet_body) + validate_no_active_session() + validate_state('i', ref_lines, ref_cursor) + ensure_clean_state() + end + + -- Basic cases + child.lua('vim.loop.hrtime = function() return 101 end') -- mock reproducible `math.randomseed` + validate('$RANDOM ${RANDOM}', { '491985 873024' }, { 1, 13 }) + + child.fn.setreg('"', 'abc\n') + validate('\n\t$TM_SELECTED_TEXT\n', { '', '\tabc', '', '' }, { 4, 6 }) + + -- Placeholders + validate('var=$AAA', { 'var=' }, { 1, 4 }) + validate('var=${AAA}', { 'var=' }, { 1, 4 }) + validate('var=${AAA:placeholder}', { 'var=placeholder' }, { 1, 15 }) + validate('var=${BBB:${AAA:placeholder}}', { 'var=placeholder' }, { 1, 15 }) + + child.fn.setenv('AAA', 'aaa') + validate('var=$AAA', { 'var=aaa' }, { 1, 7 }) + validate('var=${AAA}', { 'var=aaa' }, { 1, 7 }) + validate('var=${AAA:placeholder}', { 'var=aaa' }, { 1, 7 }) + validate('var=${BBB:${AAA:placeholder}}', { 'var=aaa' }, { 1, 7 }) +end + +T['Various snippets']['tabstop'] = function() + local validate = function(snippet_body) + start_session(snippet_body) + validate_active_session() + child.expect_screenshot() + ensure_clean_state() + end + + -- Only tabstops + validate('$1') + validate('$1$0') + + -- Other special tabstop cases are scattered across narrower test cases +end + +T['Various snippets']['choice'] = function() + -- Basic case. More tests are in 'Session'-'choices' + start_session('${1|bb,aa|}') + validate_active_session() + validate_pumitems({ 'bb', 'aa' }) + -- Should insert first choice + validate_state('i', { 'bb' }, { 1, 0 }) +end + +T['Various snippets']['transform'] = function() + -- Should ignore present transform (for now) in both variables and tabstops + child.fn.setreg('"', 'abc\n') + start_session('Upcase=${TM_SELECTED_TEXT/.*/upcase/}') + validate_state('i', { 'Upcase=abc', '' }, { 2, 0 }) + ensure_clean_state() + + start_session('Upcase=${1/.*/upcase/};') + validate_active_session() + validate_state('i', { 'Upcase=;' }, { 1, 7 }) +end + +T['Various snippets']['placeholders'] = function() + -- Placeholders should be removed during typing + start_session('T1=${1:} T0=${0:}') + child.expect_screenshot() + type_keys('x') + child.expect_screenshot() + jump('next') + child.expect_screenshot() + type_keys('y') + -- - Should also remove final tabstop's placeholder + child.expect_screenshot() + ensure_clean_state() + + -- Multiline placeholder + start_session('T1=${1:aa\nbb\n} T0=$0') + child.expect_screenshot() + type_keys('x') + child.expect_screenshot() + ensure_clean_state() + + -- Placeholder in single final tabstop should result in active session + start_session('Text ${0:placeholder}') + validate_active_session() + child.expect_screenshot() + type_keys('x') + validate_no_active_session() +end + +T['Tricky snippets'] = new_set() + +T['Tricky snippets']['nested empty tabstops'] = function() + -- This should normalize into '${1:${2:$3}} $2 $3' + start_session('${1:${2:$3}} ${2:$3} $3') + -- Should show every tabstop with inline text + child.expect_screenshot() + jump('next') + child.expect_screenshot() + jump('next') + child.expect_screenshot() + + type_keys('a') + child.expect_screenshot() + + -- Should remove text from $3 from placeholder + jump('prev') + type_keys('b') + child.expect_screenshot() + + -- Should remove text from $2 as placeholder + jump('prev') + type_keys('c') + child.expect_screenshot() +end + +T['Tricky snippets']['squashed linked empty interleaving tabstops'] = function() + start_session('$1$2$1$2$1') + child.expect_screenshot() + type_keys('a') + child.expect_screenshot() + + jump('next') + type_keys('b') + child.expect_screenshot() + + type_keys('') + child.expect_screenshot() + + jump('prev') + type_keys('') + child.expect_screenshot() +end + +T['Tricky snippets']['squashed linked empty consecutive tabstops'] = function() + start_session('$1$1$1$2$2') + child.expect_screenshot() + type_keys('a') + child.expect_screenshot() + + jump('next') + type_keys('b') + child.expect_screenshot() + + type_keys('') + child.expect_screenshot() + + jump('prev') + type_keys('') + child.expect_screenshot() +end + +T['Tricky snippets']['squashed linked tabstops with placeholders'] = function() + start_session('$1${2:aa}$1${2:aa}$1') + child.expect_screenshot() + type_keys('x') + child.expect_screenshot() + + jump('next') + type_keys('y') + child.expect_screenshot() +end + +T['Tricky snippets']['final tabstop is nested'] = function() + -- Only nested + start_session('T1=${1:}>}') + jump('next') + eq(get_cur_tabstop(), '2') + type_keys('x') + validate_state('i', { 'T1=' }, { 1, 8 }) + jump('next') + eq(get_cur_tabstop(), '1') + ensure_clean_state() + + -- Nested and outside + start_session('T1=${1:$0} T0=$0') + type_keys('x') + validate_state('i', { 'T1=x T0=' }, { 1, 4 }) + jump('next') + eq(get_cur_tabstop(), '0') +end + +T['Tricky snippets']['tricky choices'] = function() + -- Should not show popup if there are no choices + start_session('No choice ${1||}') + validate_active_session() + validate_no_pumvisible() + ensure_clean_state() + + -- Same ignore repeated choices + start_session('Same choices ${1|b,a,b,a,c|}') + validate_active_session() + validate_pumitems({ 'b', 'a', 'c' }) + ensure_clean_state() + + -- Should ignore empty choices + start_session('Empty choices ${1|b,,a,,|}') + validate_active_session() + validate_pumitems({ 'b', 'a' }) + ensure_clean_state() +end + +T['Tricky snippets']['tabstop nested inside itself'] = function() + -- Should not be allowed + expect.error(function() start_session('${1:$1}') end, 'Placeholder can not contain its tabstop') +end + +T['Tricky snippets']['intertwined nested tabstops'] = function() + -- Should be normalized into 'T1=${1:} and T2=$2' during `parse()` + start_session('T1=${1:} and T2=${2:}') + child.expect_screenshot() + type_keys('x') + child.expect_screenshot() + jump('next') + type_keys('y') + child.expect_screenshot() + ensure_clean_state() + + -- Order matters + start_session('T1=${1:} and T2=${2:}') + jump('next') + child.expect_screenshot() + type_keys('x') + child.expect_screenshot() + jump('prev') + type_keys('y') + child.expect_screenshot() +end + +T['Mappings'] = new_set({ + hooks = { + pre_case = function() + child.lua([[MiniSnippets.config.snippets = { + { prefix = "tt", body = "T1=$1 T0=$0" }, + { prefix = "uu", body = "U1=$1 U0=$0" }, + }]]) + end, + }, +}) + +local has_mapping = function(lhs) return child.cmd_capture('imap ' .. lhs):find('No mapping') == nil end + +T['Mappings']['works'] = function() + -- `default_insert()` mappings should be present only for active session(s) + eq(has_mapping(''), true) + eq(has_mapping(''), false) + eq(has_mapping(''), false) + eq(has_mapping(''), false) + + type_keys('i', 'tt', '') + validate_active_session() + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + + type_keys('') + eq(get_cur_tabstop(), '0') + validate_state('i', { 'T1= T0=' }, { 1, 7 }) + + type_keys('') + eq(get_cur_tabstop(), '1') + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + + validate_active_session() + type_keys('') + validate_no_active_session() + + eq(has_mapping(''), true) + eq(has_mapping(''), false) + eq(has_mapping(''), false) + eq(has_mapping(''), false) + + -- Should work even if using `default_insert()` directly + default_insert({ body = 'U1=$1' }) + type_keys('') + eq(get_cur_tabstop(), '0') + type_keys('') + eq(get_cur_tabstop(), '1') + type_keys('') + validate_no_active_session() +end + +T['Mappings']['works with different keys'] = function() + child.restart() + load_module({ + snippets = { { prefix = 'tt', body = 'T1=$1 T0=$0' } }, + mappings = { expand = '', jump_next = '', jump_prev = '', stop = '' }, + }) + + type_keys('i', 'tt', '') + validate_active_session() + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + + type_keys('') + eq(get_cur_tabstop(), '0') + validate_state('i', { 'T1= T0=' }, { 1, 7 }) + + type_keys('') + eq(get_cur_tabstop(), '1') + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + + validate_active_session() + type_keys('') + validate_no_active_session() + + -- Should work even if using `default_insert()` directly + default_insert({ body = 'U1=$1' }) + type_keys('') + eq(get_cur_tabstop(), '0') + type_keys('') + eq(get_cur_tabstop(), '1') + type_keys('') + validate_no_active_session() +end + +T['Mappings']['work across buffers'] = function() + local init_buf, other_buf = get_buf(), new_buf() + type_keys('i', 'tt', '') + validate_state('i', { 'T1= T0=' }, { 1, 3 }) + eq(get_cur_tabstop(), '1') + + set_buf(other_buf) + type_keys('') + eq(get_buf(), init_buf) + eq(get_cur_tabstop(), '0') + + set_buf(other_buf) + type_keys('') + eq(get_buf(), init_buf) + eq(get_cur_tabstop(), '1') + + set_buf(other_buf) + validate_active_session() + type_keys('') + validate_no_active_session() +end + +T['Mappings']['`default_insert` mappings respect buffer-local config'] = function() + child.b.minisnippets_config = { mappings = { stop = '' } } + + eq(has_mapping(''), false) + eq(has_mapping(''), false) + type_keys('i', 'tt', '') + eq(has_mapping(''), false) + eq(has_mapping(''), true) +end + +T['Mappings']['`default_insert` mappings are present for all nested sessions'] = function() + type_keys('i', 'tt', '') + type_keys('uu', '') + validate_n_sessions(2) + + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 6 }) + type_keys('') + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 10 }) + type_keys('') + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 6 }) + type_keys('') + validate_state('i', { 'T1=U1= U0= T0=' }, { 1, 6 }) + validate_n_sessions(1) +end + +T['Mappings']['`default_insert` mappings cache and restore global conflicting mappings'] = function() + child.api.nvim_set_keymap('i', '', 'lua print(1)', {}) + child.api.nvim_set_keymap('i', '', 'lua print(2)', {}) + child.api.nvim_set_keymap('i', '', 'lua print(3)', {}) + + local get_map_rhs = function(lhs) return child.fn.maparg(lhs, 'i', false, true).rhs end + + -- Should cache and restore only after there is no active session + type_keys('i', 'tt', '') + no_eq(get_map_rhs(''), 'lua print(1)') + no_eq(get_map_rhs(''), 'lua print(2)') + no_eq(get_map_rhs(''), 'lua print(3)') + + type_keys('uu', '') + no_eq(get_map_rhs(''), 'lua print(1)') + no_eq(get_map_rhs(''), 'lua print(2)') + no_eq(get_map_rhs(''), 'lua print(3)') + + type_keys('') + no_eq(get_map_rhs(''), 'lua print(1)') + no_eq(get_map_rhs(''), 'lua print(2)') + no_eq(get_map_rhs(''), 'lua print(3)') + + type_keys('') + eq(get_map_rhs(''), 'lua print(1)') + eq(get_map_rhs(''), 'lua print(2)') + eq(get_map_rhs(''), 'lua print(3)') + + ensure_clean_state() + + -- Should always cache map data just before first active session + child.api.nvim_set_keymap('i', '', 'lua print(111)', {}) + type_keys('i', 'tt', '') + no_eq(get_map_rhs(''), 'lua print(111)') + type_keys('') + eq(get_map_rhs(''), 'lua print(111)') +end + +T['Examples'] = new_set() + +T['Examples']['stop session after jump to final tabstop'] = function() + child.lua([[ + local fin_stop = function(args) if args.data.tabstop_to == '0' then MiniSnippets.session.stop() end end + vim.api.nvim_create_autocmd('User', { pattern = 'MiniSnippetsSessionJump', callback = fin_stop }) + ]]) + start_session('T1=$1; T0=$0') + validate_active_session() + jump('next') + validate_no_active_session() +end + +T['Examples']['select from all'] = function() + child.lua([[ + local rhs = function() MiniSnippets.expand({ match = false }) end + vim.keymap.set('i', '', rhs, { desc = 'Expand all' }) + + MiniSnippets.config.snippets = { + { prefix = 'aa', body = 'AA=$1' }, + { prefix = 'ab', body = 'AB=$1' }, + { prefix = 'xx', body = 'XX=$1' }, + } + ]]) + + mock_select(3) + type_keys('i', 'a', '') + validate_state('i', { 'aXX=' }, { 1, 4 }) +end + +T['Examples']['/ mappings'] = function() + child.setup() + load_module({ + snippets = { { prefix = 'l', body = 'T1=$1 T0=0' } }, + mappings = { expand = '', jump_next = '', jump_prev = '' }, + }) + child.lua([[ + local minisnippets = require('mini.snippets') + local match_strict = function(snippets) + return minisnippets.default_match(snippets, { pattern_fuzzy='%S+' }) + end + minisnippets.setup({ + snippets = { { prefix = 'l', body = 'T1=$1 T0=0' } }, + mappings = { expand = '', jump_next = '' }, + expand = { match = match_strict }, + }) + local expand_or_jump = function() + local can_expand = #MiniSnippets.expand({ insert = false }) > 0 + if can_expand then vim.schedule(MiniSnippets.expand); return '' end + local is_active = MiniSnippets.session.get() ~= nil + if is_active then MiniSnippets.session.jump('next'); return '' end + return '\t' + end + local jump_prev = function() MiniSnippets.session.jump('prev') end + vim.keymap.set('i', '', expand_or_jump, { expr = true }) + vim.keymap.set('i', '', jump_prev) + ]]) + + type_keys('i', '') + eq(get_lines(), { '\t' }) + + type_keys('l', '') + validate_active_session() + eq(get_cur_tabstop(), '1') + + type_keys('l', '') + validate_n_sessions(2) + eq(get_cur_tabstop(), '1') + + type_keys('') + validate_n_sessions(2) + eq(get_cur_tabstop(), '0') + + type_keys('') + validate_n_sessions(2) + eq(get_cur_tabstop(), '1') +end + +T['Examples']['using `vim.snippet.expand()`'] = function() + if child.fn.has('nvim-0.10') == 0 then MiniTest.skip('`vim.snippet` is present only in Neovim>=0.10') end + child.lua([[ + require('mini.snippets').setup({ + snippets = { { prefix = 't', body = 'T1=$1 T2=${2:}' } }, + expand = { + insert = function(snippet, _) vim.snippet.expand(snippet.body) end + } + }) + local jump_next = function() + if vim.snippet.active({direction = 1}) then return vim.snippet.jump(1) end + end + local jump_prev = function() + if vim.snippet.active({direction = -1}) then vim.snippet.jump(-1) end + end + vim.keymap.set({ 'i', 's' }, '', jump_next) + vim.keymap.set({ 'i', 's' }, '', jump_prev) + ]]) + + type_keys('i', 't', '') + -- SHould not have active session from `default_insert()` + validate_no_active_session() + validate_state('i', { 'T1= T2=' }, { 1, 3 }) + type_keys('t1') + validate_state('i', { 'T1=t1 T2=' }, { 1, 5 }) + type_keys('') + validate_state('s', { 'T1=t1 T2=' }, { 1, 9 }) + type_keys('t2') + validate_state('i', { 'T1=t1 T2=t2' }, { 1, 11 }) + type_keys('') + validate_state('s', { 'T1=t1 T2=t2' }, { 1, 3 }) +end + +T['Examples']['`default_prepare` with cache'] = function() + child.lua([[ + _G.log = {} + local prepare_orig = MiniSnippets.default_prepare + MiniSnippets.default_prepare = function(...) + table.insert(_G.log, { ... }) + return prepare_orig(...) + end + + local cache = {} + _G.prepare_cached = function(raw_snippets) + local _, cont = MiniSnippets.default_prepare({}) + local id = 'buf=' .. cont.buf_id .. ',lang=' .. cont.lang + if cache[id] then return unpack(vim.deepcopy(cache[id])) end + local snippets = MiniSnippets.default_prepare(raw_snippets) + cache[id] = vim.deepcopy({ snippets, cont }) + return snippets, cont + end + ]]) + + child.bo.filetype = 'myft' + child.lua([[_G.prepare_cached({ { prefix = 'a', body = 'a=$1' } })]]) + eq(child.lua_get('#_G.log'), 2) + local out = child.lua_get([[_G.prepare_cached({ { prefix = 'x', body = 'x=$1' } })]]) + eq(out, { { prefix = 'a', body = 'a=$1', desc = 'a=$1' } }) + eq(child.lua_get('#_G.log'), 3) + + child.bo.filetype = 'myft2' + child.lua([[_G.prepare_cached({ { prefix = 'a', body = 'a=$1' } })]]) + eq(child.lua_get('#_G.log'), 5) +end + +return T