├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── markdownlint.yml │ ├── nvim-type-check.yml │ ├── panvimdoc.yml │ ├── pr-title.yml │ ├── stale-bot.yml │ └── stylua.yml ├── .gitignore ├── .luarc.json ├── .markdownlint.yaml ├── .stylua.toml ├── Justfile ├── LICENSE ├── README.md ├── doc └── nvim-scissors.txt ├── lua └── scissors │ ├── 1-prepare-selection.lua │ ├── 2-picker │ ├── picker-choice.lua │ ├── snacks.lua │ ├── telescope.lua │ └── vim-ui-select.lua │ ├── 3-edit-popup.lua │ ├── 4-hot-reload.lua │ ├── backdrop.lua │ ├── config.lua │ ├── init.lua │ ├── types.lua │ ├── utils.lua │ └── vscode-format │ ├── convert-object.lua │ ├── read-write.lua │ └── validate-bootstrap.lua └── plugin └── ex-commands.lua /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | max_line_length = 100 5 | end_of_line = lf 6 | charset = utf-8 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 3 10 | tab_width = 3 11 | trim_trailing_whitespace = true 12 | 13 | [*.{yml,yaml,scm,cff}] 14 | indent_style = space 15 | indent_size = 2 16 | tab_width = 2 17 | 18 | [*.py] 19 | indent_style = space 20 | indent_size = 4 21 | tab_width = 4 22 | 23 | [*.md] 24 | indent_size = 4 25 | tab_width = 4 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/displaying-a-sponsor-button-in-your-repository 2 | 3 | custom: https://www.paypal.me/ChrisGrieser 4 | ko_fi: pseudometa 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Make sure you have done the following 10 | options: 11 | - label: I have updated to the latest version of the plugin. 12 | required: true 13 | - label: 14 | I have read [the docs, and in particular the 15 | FAQ](https://github.com/chrisgrieser/nvim-scissors#table-of-contents). 16 | required: true 17 | - label: 18 | The problem is related to creating or editing snippets. (Expanding and using snippet is 19 | the responsibility of the snippet engine plugin, such as Luasnip or nvim-snippets.) 20 | required: true 21 | - type: textarea 22 | id: bug-description 23 | attributes: 24 | label: Bug Description 25 | description: A clear and concise description of the bug. 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: screenshot 30 | attributes: 31 | label: Relevant Screenshot 32 | description: 33 | If applicable, add screenshots or a screen recording to help explain your problem. 34 | - type: textarea 35 | id: reproduction-steps 36 | attributes: 37 | label: To Reproduce 38 | description: Steps to reproduce the problem 39 | placeholder: | 40 | For example: 41 | 1. Go to '...' 42 | 2. Click on '...' 43 | 3. Scroll down to '...' 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: version-info 48 | attributes: 49 | label: neovim version 50 | render: Text 51 | validations: 52 | required: true 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: "Feature Request: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | options: 11 | - label: I have read the plugin's README, including the FAQ. 12 | required: true 13 | - label: 14 | The suggestion is related to creating or editing snippets. (Expanding and using snippet 15 | is the responsibility of the snippet engine plugin, such as `Luasnip` or 16 | `nvim-snippets`.) 17 | required: true 18 | - label: The feature would be useful to more users than just me. 19 | required: true 20 | - type: textarea 21 | id: feature-requested 22 | attributes: 23 | label: Feature Requested 24 | description: A clear and concise description of the feature. 25 | validations: { required: true } 26 | - type: textarea 27 | id: problem-solved 28 | attributes: 29 | label: Problem solved 30 | description: What problem would the feature solve? 31 | validations: { required: true } 32 | - type: textarea 33 | id: alternatives 34 | attributes: 35 | label: Alternatives considered 36 | description: What alternatives are there to the feature? 37 | validations: { required: true } 38 | - type: textarea 39 | id: screenshot 40 | attributes: 41 | label: Relevant Screenshot 42 | description: If applicable, add screenshots or a screen recording to help explain the request. 43 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore(dependabot): " 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What problem does this PR solve? 2 | 3 | ## How does the PR solve it? 4 | 5 | ## Checklist 6 | - [ ] Used only `camelCase` variable names. 7 | - [ ] If functionality is added or modified, also made respective changes to the 8 | `README.md` (the `.txt` file is auto-generated and does not need to be 9 | modified). 10 | -------------------------------------------------------------------------------- /.github/workflows/markdownlint.yml: -------------------------------------------------------------------------------- 1 | name: Markdownlint check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.md" 8 | - ".github/workflows/markdownlint.yml" 9 | - ".markdownlint.*" # markdownlint config files 10 | pull_request: 11 | paths: 12 | - "**.md" 13 | 14 | jobs: 15 | markdownlint: 16 | name: Markdownlint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: DavidAnson/markdownlint-cli2-action@v20 21 | with: 22 | globs: "**/*.md" 23 | -------------------------------------------------------------------------------- /.github/workflows/nvim-type-check.yml: -------------------------------------------------------------------------------- 1 | name: nvim type check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ["**.lua"] 7 | pull_request: 8 | paths: ["**.lua"] 9 | 10 | jobs: 11 | build: 12 | name: nvim type check 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: stevearc/nvim-typecheck-action@v2 17 | -------------------------------------------------------------------------------- /.github/workflows/panvimdoc.yml: -------------------------------------------------------------------------------- 1 | name: panvimdoc 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - README.md 8 | - .github/workflows/panvimdoc.yml 9 | workflow_dispatch: {} # allows manual execution 10 | 11 | permissions: 12 | contents: write 13 | 14 | #─────────────────────────────────────────────────────────────────────────────── 15 | 16 | jobs: 17 | docs: 18 | runs-on: ubuntu-latest 19 | name: README.md to vimdoc 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: git pull # fix failure when multiple commits are pushed in succession 23 | - run: mkdir -p doc 24 | 25 | - name: panvimdoc 26 | uses: kdheepak/panvimdoc@main 27 | with: 28 | vimdoc: ${{ github.event.repository.name }} 29 | version: "Neovim" 30 | demojify: true 31 | treesitter: true 32 | 33 | - run: git pull 34 | - name: push changes 35 | uses: stefanzweifel/git-auto-commit-action@v5 36 | with: 37 | commit_message: "chore: auto-generate vimdocs" 38 | branch: ${{ github.head_ref }} 39 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: PR title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | 12 | permissions: 13 | pull-requests: read 14 | 15 | jobs: 16 | semantic-pull-request: 17 | name: Check PR title 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@v5 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | requireScope: false 25 | subjectPattern: ^(?![A-Z]).+$ # disallow title starting with capital 26 | types: | # add `improv` to the list of allowed types 27 | improv 28 | fix 29 | feat 30 | refactor 31 | build 32 | ci 33 | style 34 | test 35 | chore 36 | perf 37 | docs 38 | break 39 | revert 40 | -------------------------------------------------------------------------------- /.github/workflows/stale-bot.yml: -------------------------------------------------------------------------------- 1 | name: Stale bot 2 | on: 3 | schedule: 4 | - cron: "18 04 * * 3" 5 | 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Close stale issues 15 | uses: actions/stale@v9 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | # DOCS https://github.com/actions/stale#all-options 20 | days-before-stale: 180 21 | days-before-close: 7 22 | stale-issue-label: "Stale" 23 | stale-issue-message: | 24 | This issue has been automatically marked as stale. 25 | **If this issue is still affecting you, please leave any comment**, for example "bump", and it will be kept open. 26 | close-issue-message: | 27 | This issue has been closed due to inactivity, and will not be monitored. 28 | -------------------------------------------------------------------------------- /.github/workflows/stylua.yml: -------------------------------------------------------------------------------- 1 | name: Stylua check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ["**.lua"] 7 | pull_request: 8 | paths: ["**.lua"] 9 | 10 | jobs: 11 | stylua: 12 | name: Stylua 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: JohnnyMorganz/stylua-action@v4 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | version: latest 20 | args: --check . 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # help-tags auto-generated by lazy.nvim 2 | doc/tags 3 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime.version": "LuaJIT", 3 | "diagnostics.globals": ["vim"] 4 | } 5 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Defaults https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml 2 | # DOCS https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md 3 | #─────────────────────────────────────────────────────────────────────────────── 4 | 5 | # MODIFIED SETTINGS 6 | blanks-around-headings: 7 | lines_below: 0 # space waster 8 | ul-style: { style: sublist } 9 | 10 | # not autofixable 11 | ol-prefix: { style: ordered } 12 | line-length: 13 | tables: false 14 | code_blocks: false 15 | no-inline-html: 16 | allowed_elements: [img, details, summary, kbd, a, br] 17 | 18 | #───────────────────────────────────────────────────────────────────────────── 19 | # DISABLED 20 | ul-indent: false # not compatible with using tabs 21 | no-hard-tabs: false # taken care of by editorconfig 22 | blanks-around-lists: false # space waster 23 | first-line-heading: false # e.g., ignore-comments 24 | no-emphasis-as-heading: false # sometimes useful 25 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/JohnnyMorganz/StyLua#options 2 | column_width = 100 3 | indent_type = "Tabs" 4 | indent_width = 3 5 | quote_style = "AutoPreferDouble" 6 | call_parentheses = "NoSingleTable" 7 | collapse_simple_statement = "Always" 8 | 9 | [sort_requires] 10 | enabled = true 11 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set quiet := true 2 | 3 | masonPath := "$HOME/.local/share/nvim/mason/bin/" 4 | 5 | #─────────────────────────────────────────────────────────────────────────────── 6 | 7 | stylua: 8 | #!/usr/bin/env zsh 9 | {{ masonPath }}/stylua --check --output-format=summary . && return 0 10 | {{ masonPath }}/stylua . 11 | echo "\nFiles formatted." 12 | 13 | lua_ls_check: 14 | {{ masonPath }}/lua-language-server --check . 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Christopher Grieser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # nvim-scissors ✂️ 3 | 4 | 5 | badge 6 | 7 | Automagical editing and creation of snippets. 8 | 9 | 10 | 11 | 12 | 13 | ## Table of contents 14 | 15 | 16 | 17 | - [Features](#features) 18 | - [Rationale](#rationale) 19 | - [Requirements](#requirements) 20 | - [Installation](#installation) 21 | * [nvim-scissors](#nvim-scissors) 22 | * [Snippet engine setup](#snippet-engine-setup) 23 | + [LuaSnip](#luasnip) 24 | + [mini.snippets](#minisnippets) 25 | + [blink.cmp](#blinkcmp) 26 | + [basics-language-server](#basics-language-server) 27 | + [nvim-snippets](#nvim-snippets) 28 | + [vim-vsnip](#vim-vsnip) 29 | + [yasp.nvim](#yaspnvim) 30 | - [Usage](#usage) 31 | * [Starting `nvim-scissors`](#starting-nvim-scissors) 32 | * [Editing snippets in the popup window](#editing-snippets-in-the-popup-window) 33 | - [Configuration](#configuration) 34 | - [Cookbook & FAQ](#cookbook--faq) 35 | * [Introduction to the VSCode-style snippet format](#introduction-to-the-vscode-style-snippet-format) 36 | * [Tabstops and variables](#tabstops-and-variables) 37 | * [friendly-snippets](#friendly-snippets) 38 | * [Edit snippet title or description](#edit-snippet-title-or-description) 39 | * [Version controlling snippets & snippet file formatting](#version-controlling-snippets--snippet-file-formatting) 40 | * [Snippets on visual selections (`Luasnip` only)](#snippets-on-visual-selections-luasnip-only) 41 | * [Auto-triggered snippets (`Luasnip` only)](#auto-triggered-snippets-luasnip-only) 42 | - [About the author](#about-the-author) 43 | 44 | 45 | 46 | ## Features 47 | - Add new snippets, edit snippets, or delete snippets on the fly. 48 | - Syntax highlighting while you edit the snippet. Includes highlighting of 49 | tabstops and placeholders such as `$0`, `${2:foobar}`, or `$CLIPBOARD` 50 | - Automagical conversion from buffer text to JSON string. 51 | - Intuitive UI for editing the snippet, dynamically adapting the number of 52 | prefixes. 53 | - Automatic hot-reloading of any changes, so you do not have to restart nvim for 54 | changes to take effect. 55 | - Optional JSON-formatting and sorting of the snippet file. ([Useful when 56 | version-controlling your snippet 57 | collection](#version-controlling-snippets--snippet-file-formatting).) 58 | - Snippet/file selection via `telescope`, `snacks`, or `vim.ui.select`. 59 | - Automatic bootstrapping of the snippet folder or new snippet files if needed. 60 | - Supports only [VSCode-style 61 | snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_create-your-own-snippets). 62 | 63 | > [!TIP] 64 | > You can use 65 | > [snippet-converter.nvim](https://github.com/smjonas/snippet-converter.nvim) to 66 | > convert your snippets to the VSCode format. 67 | 68 | ## Rationale 69 | - The [VSCode snippet 70 | format](https://code.visualstudio.com/docs/editor/userdefinedsnippets) is the 71 | closest thing to a standard regarding snippets. It is used by 72 | [friendly-snippets](https://github.com/rafamadriz/friendly-snippets) and 73 | supported by most snippet engine plugins for nvim. 74 | - However, VSCode snippets are stored as JSON, which are a pain to modify 75 | manually. This plugin alleviates that pain by automagically writing the JSON 76 | for you. 77 | 78 | ## Requirements 79 | - nvim 0.10+ 80 | - Snippets saved in the [VSCode-style snippet 81 | format](#introduction-to-the-vscode-style-snippet-format). 82 | - *Recommended*: [telescope](https://github.com/nvim-telescope/telescope.nvim) 83 | OR [snacks.nvim](https://github.com/folke/snacks.nvim). Without one of them, 84 | the plugin falls back to `vim.ui.select`, which still works but lacks search 85 | and snippet previews. 86 | - A snippet engine that can load VSCode-style snippets, such as: 87 | * [LuaSnip](https://github.com/L3MON4D3/LuaSnip) 88 | * [mini.snippets](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-snippets.md) 89 | * [blink.cmp](https://github.com/Saghen/blink.cmp) 90 | * [basics-language-server](https://github.com/antonk52/basics-language-server/) 91 | * [nvim-snippets](https://github.com/garymjr/nvim-snippets) 92 | * [vim-vsnip](https://github.com/hrsh7th/vim-vsnip) 93 | * [yasp.nvim](https://github.com/DimitrisDimitropoulos/yasp.nvim) 94 | - *Optional*: Treesitter parsers for the languages you want syntax highlighting 95 | for. 96 | 97 | ## Installation 98 | 99 | ### nvim-scissors 100 | 101 | ```lua 102 | -- lazy.nvim 103 | { 104 | "chrisgrieser/nvim-scissors", 105 | dependencies = "nvim-telescope/telescope.nvim", -- if using telescope 106 | opts = { 107 | snippetDir = "path/to/your/snippetFolder", 108 | } 109 | }, 110 | 111 | -- packer 112 | use { 113 | "chrisgrieser/nvim-scissors", 114 | dependencies = "nvim-telescope/telescope.nvim", -- if using telescope 115 | config = function() 116 | require("scissors").setup ({ 117 | snippetDir = "path/to/your/snippetFolder", 118 | }) 119 | end, 120 | } 121 | ``` 122 | 123 | ### Snippet engine setup 124 | In addition, your snippet engine needs to point to the same snippet folder as 125 | `nvim-scissors`: 126 | 127 | > [!TIP] 128 | > `vim.fn.stdpath("config")` returns the path to your nvim config. 129 | 130 | #### LuaSnip 131 | 132 | ```lua 133 | require("luasnip.loaders.from_vscode").lazy_load { 134 | paths = { "path/to/your/snippetFolder" }, 135 | } 136 | ``` 137 | 138 | 139 | #### mini.snippets 140 | 141 | `mini.snippets` preferred snippet location is any `snippets/` directory in the 142 | `runtimepath`. For manually maintained snippets the best location is the user 143 | config directory, which requires the following `nvim-scissors` setup: 144 | 145 | ```lua 146 | require("scissors").setup({ 147 | snippetDir = vim.fn.stdpath("config") .. "/snippets", 148 | }) 149 | ``` 150 | 151 | The `mini.snippets` setup requires explicit definition of loaders. Following its 152 | [Quickstart](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-snippets.md#quickstart) 153 | guide should be enough to make it respect snippets from 'snippets/' directory 154 | inside user config. **Note**: `nvim-scissors` works only with VSCode-style 155 | snippet files (not Lua files or JSON arrays), and requires a 156 | [`package.json` for the VSCode 157 | format](#introduction-to-the-vscode-style-snippet-format). 158 | 159 | 160 | #### blink.cmp 161 | 162 | 163 | ```lua 164 | require("blink.cmp").setup { 165 | sources = { 166 | providers = { 167 | snippets = { 168 | opts = { 169 | search_paths = { "path/to/your/snippetFolder" }, 170 | }, 171 | } 172 | } 173 | } 174 | } 175 | ``` 176 | 177 | It is recommended to use the latest release of `blink.cmp` for hot-reloading to 178 | work. 179 | 180 | 181 | #### basics-language-server 182 | 183 | 184 | ```lua 185 | require("lspconfig").basics_ls.setup({ 186 | settings = { 187 | snippet = { 188 | enable = true, 189 | sources = { "path/to/your/snippetFolder" } 190 | }, 191 | } 192 | }) 193 | ``` 194 | 195 | 196 | #### nvim-snippets 197 | 198 | 199 | ```lua 200 | require("nvim-snippets").setup { 201 | search_paths = { "path/to/your/snippetFolder" }, 202 | } 203 | ``` 204 | 205 | 206 | #### vim-vsnip 207 | 208 | 209 | ```lua 210 | vim.g.vsnip_snippet_dir = "path/to/your/snippetFolder" 211 | -- OR 212 | vim.g.vsnip_snippet_dirs = { "path/to/your/snippetFolder" } 213 | ``` 214 | 215 | 216 | #### yasp.nvim 217 | 218 | 219 | ```lua 220 | require("yasp").setup { 221 | paths = { 222 | vim.fn.stdpath("config") .. "/snippets/package.json", 223 | }, 224 | descs = { "user snippets" }, 225 | } 226 | ``` 227 | 228 | ## Usage 229 | 230 | ### Starting `nvim-scissors` 231 | The plugin provides two lua functions, `.addNewSnippet()` and `.editSnippet()`: 232 | 233 | ```lua 234 | vim.keymap.set( 235 | "n", 236 | "se", 237 | function() require("scissors").editSnippet() end, 238 | { desc = "Snippet: Edit" } 239 | ) 240 | 241 | -- when used in visual mode, prefills the selection as snippet body 242 | vim.keymap.set( 243 | { "n", "x" }, 244 | "sa", 245 | function() require("scissors").addNewSnippet() end, 246 | { desc = "Snippet: Add" } 247 | ) 248 | ``` 249 | 250 | You can also use `:ScissorsAddNewSnippet` and `:ScissorsEditSnippet` if you 251 | prefer ex commands. 252 | 253 | The `:ScissorsAddSnippet` ex command also accepts a range to prefill the snippet 254 | body (for example `:'<,'> ScissorsAddNewSnippet` or `:3 ScissorsAddNewSnippet`). 255 | 256 | ### Editing snippets in the popup window 257 | The popup is just one window, so you can move between the prefix area and the 258 | body with `j` and `k` or any other movement command. ("Prefix" is how trigger 259 | words are referred to in the VSCode format.) 260 | 261 | Use `showHelp` (default keymap: `?`) to show a notification containing all 262 | keymaps. 263 | 264 | The popup intelligently adapts to changes in the prefix area: Each line 265 | represents one prefix, and creating or removing lines in that area thus changes 266 | the number of prefixes. 267 | 268 | Showcase prefix change 269 | 270 | ## Configuration 271 | The `.setup()` call is optional. 272 | 273 | ```lua 274 | -- default settings 275 | require("scissors").setup { 276 | snippetDir = vim.fn.stdpath("config") .. "/snippets", 277 | editSnippetPopup = { 278 | height = 0.4, -- relative to the window, between 0-1 279 | width = 0.6, 280 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 281 | keymaps = { 282 | -- if not mentioned otherwise, the keymaps apply to normal mode 283 | cancel = "q", 284 | saveChanges = "", -- alternatively, can also use `:w` 285 | goBackToSearch = "", 286 | deleteSnippet = "", 287 | duplicateSnippet = "", 288 | openInFile = "", 289 | insertNextPlaceholder = "", -- insert & normal mode 290 | showHelp = "?", 291 | }, 292 | }, 293 | 294 | snippetSelection = { 295 | picker = "auto", ---@type "auto"|"telescope"|"snacks"|"vim.ui.select" 296 | 297 | telescope = { 298 | -- By default, the query only searches snippet prefixes. Set this to 299 | -- `true` to also search the body of the snippets. 300 | alsoSearchSnippetBody = false, 301 | 302 | -- accepts the common telescope picker config 303 | opts = { 304 | layout_strategy = "horizontal", 305 | layout_config = { 306 | horizontal = { width = 0.9 }, 307 | preview_width = 0.6, 308 | }, 309 | }, 310 | }, 311 | 312 | -- `snacks` picker configurable via snacks config, 313 | -- see https://github.com/folke/snacks.nvim/blob/main/docs/picker.md 314 | }, 315 | 316 | -- `none` writes as a minified json file using `vim.encode.json`. 317 | -- `yq`/`jq` ensure formatted & sorted json files, which is relevant when 318 | -- you version control your snippets. To use a custom formatter, set to a 319 | -- list of strings, which will then be passed to `vim.system()`. 320 | -- TIP: `jq` is already pre-installed on newer versions of macOS. 321 | ---@type "yq"|"jq"|"none"|string[] 322 | jsonFormatter = "none", 323 | 324 | backdrop = { 325 | enabled = true, 326 | blend = 50, -- between 0-100 327 | }, 328 | icons = { 329 | scissors = "󰩫", 330 | }, 331 | } 332 | ``` 333 | 334 | ## Cookbook & FAQ 335 | 336 | ### Introduction to the VSCode-style snippet format 337 | This plugin requires that you have a valid VSCode snippet folder. In addition to 338 | saving the snippets in the required JSON format, there must also be a 339 | `package.json` file at the root of the snippet folder, specifying which files 340 | should be used for which languages. 341 | 342 | Example file structure inside the `snippetDir`: 343 | 344 | ```txt 345 | . 346 | ├── package.json 347 | ├── python.json 348 | ├── project-specific 349 | │ └── nvim-lua.json 350 | ├── javascript.json 351 | └── allFiletypes.json 352 | ``` 353 | 354 | Example `package.json`: 355 | 356 | ```json 357 | { 358 | "contributes": { 359 | "snippets": [ 360 | { 361 | "language": "python", 362 | "path": "./python.json" 363 | }, 364 | { 365 | "language": "lua", 366 | "path": "./project-specific/nvim-lua.json" 367 | }, 368 | { 369 | "language": ["javascript", "typescript"], 370 | "path": "./javascript.json" 371 | }, 372 | { 373 | "language": "all", 374 | "path": "./allFiletypes.json" 375 | } 376 | ] 377 | }, 378 | "name": "my-snippets" 379 | } 380 | ``` 381 | 382 | > [!NOTE] 383 | > The special filetype `all` enables the snippets globally, regardless of 384 | > filetype. 385 | 386 | Example snippet file (here: `nvim-lua.json`): 387 | 388 | ```json 389 | { 390 | "autocmd (Filetype)": { 391 | "body": [ 392 | "vim.api.nvim_create_autocmd(\"FileType\", {", 393 | "\tpattern = \"${1:ft}\",", 394 | "\tcallback = function()", 395 | "\t\t$0", 396 | "\tend,", 397 | "})" 398 | ], 399 | "prefix": "autocmd (Filetype)" 400 | }, 401 | "file exists": { 402 | "body": "local fileExists = vim.uv.fs_stat(\"${1:filepath}\") ~= nil", 403 | "prefix": "file exists" 404 | }, 405 | } 406 | ``` 407 | 408 | For details, read the official VSCode snippet documentation: 409 | - [Snippet file specification](https://code.visualstudio.com/docs/editor/userdefinedsnippets) 410 | - [`package.json` specification](https://code.visualstudio.com/api/language-extensions/snippet-guide) 411 | - [LuaSnip-specific additions to the format](https://github.com/L3MON4D3/LuaSnip/blob/master/DOC.md#vs-code) 412 | 413 | ### Tabstops and variables 414 | [Tabstops](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_tabstops) 415 | are denoted by `$1`, `$2`, `$3`, etc., with `$0` being the last tabstop. They 416 | support placeholders such as `${1:foobar}`. 417 | 418 | > [!NOTE] 419 | > Due to the use of `$` in the snippet syntax, any *literal* `$` needs to be 420 | > escaped as `\$`. 421 | 422 | Furthermore, there are various variables you can use, such as `$TM_FILENAME` or 423 | `$LINE_COMMENT`. [See here for a full list of 424 | variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). 425 | 426 | 427 | ### friendly-snippets 428 | 429 | Even though the snippets from the [friendly-snippets](https://github.com/rafamadriz/friendly-snippets) 430 | repository are written in the VSCode-style format, editing them directly is not 431 | supported. The reason being that any changes made would be overwritten as soon 432 | as the `friendly-snippets` repository is updated (which happens fairly 433 | regularly). Unfortunately, there is little `nvim-scissors` can do about that. 434 | 435 | What you can do, however, is to copy individual snippets files from the 436 | `friendly-snippets` repository into your own snippet folder, and edit them there. 437 | 438 | ### Edit snippet title or description 439 | `nvim-scissors` only allows to edit the snippet prefix and snippet body, to keep 440 | the UI as simple as possible. For the few cases where you need to edit a 441 | snippet's title or description, you can use the `openInFile` keymap and edit 442 | them directly in the snippet file. 443 | 444 | ### Version controlling snippets & snippet file formatting 445 | This plugin writes JSON files via `vim.encode.json()`. That method saves 446 | the file in minified form and does not have a 447 | deterministic order of dictionary keys. 448 | 449 | Both, minification and unstable key order, are a problem if you 450 | version-control your snippet collection. To solve this issue, `nvim-scissors` 451 | lets you optionally unminify and sort the JSON files via `yq` or `jq` after 452 | updating a snippet. (Both are also available via 453 | [mason.nvim](https://github.com/williamboman/mason.nvim).) 454 | 455 | It is recommended to run `yq`/`jq` once on all files in your snippet collection, 456 | since the first time you edit a file, you would still get a large diff from the 457 | initial sorting. You can do so with `yq` using this command: 458 | 459 | ```bash 460 | cd "/your/snippet/dir" 461 | find . -name "*.json" | xargs -I {} yq --inplace --output-format=json "sort_keys(..)" {} 462 | ``` 463 | 464 | How to do the same with `jq` is left as an exercise to the reader. 465 | 466 | ### Snippets on visual selections (`Luasnip` only) 467 | With `Luasnip`, this is an opt-in feature, enabled via: 468 | 469 | ```lua 470 | require("luasnip").setup { 471 | store_selection_keys = "", 472 | } 473 | ``` 474 | 475 | In your VSCode-style snippet, use the token `$TM_SELECTED_TEXT` at the location 476 | where you want the selection to be inserted. (It's roughly the equivalent of 477 | `LS_SELECT_RAW` in the `Luasnip` syntax.) 478 | 479 | Then, in visual mode, press the key from `store_selection_keys`. The selection 480 | disappears, and you are put in insert mode. The next snippet you now trigger 481 | is going to have `$TM_SELECTED_TEXT` replaced with your selection. 482 | 483 | ### Auto-triggered snippets (`Luasnip` only) 484 | While the VSCode snippet format does not support auto-triggered snippets, 485 | `LuaSnip` allows you to [specify auto-triggering in the VSCode-style JSON 486 | files by adding the `luasnip` key](https://github.com/L3MON4D3/LuaSnip/blob/master/DOC.md#vs-code). 487 | 488 | `nvim-scissors` does not touch any keys other than `prefix` and `body` in the 489 | JSON files, so any additions like the `luasnip` key are preserved. 490 | 491 | > [!TIP] 492 | > You can use the `openInFile` keymap to directory open JSON file at the 493 | > snippet's location to make edits there easier. 494 | 495 | ## About the author 496 | In my day job, I am a sociologist studying the social mechanisms underlying the 497 | digital economy. For my PhD project, I investigate the governance of the app 498 | economy and how software ecosystems manage the tension between innovation and 499 | compatibility. If you are interested in this subject, feel free to get in touch. 500 | 501 | - [Website](https://chris-grieser.de/) 502 | - [Mastodon](https://pkm.social/@pseudometa) 503 | - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser) 504 | - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/) 505 | 506 | Buy Me a Coffee at ko-fi.com 509 | -------------------------------------------------------------------------------- /doc/nvim-scissors.txt: -------------------------------------------------------------------------------- 1 | *nvim-scissors.txt* For Neovim Last change: 2025 May 03 2 | 3 | ============================================================================== 4 | Table of Contents *nvim-scissors-table-of-contents* 5 | 6 | 1. nvim-scissors |nvim-scissors-nvim-scissors-| 7 | - Table of contents |nvim-scissors-nvim-scissors--table-of-contents| 8 | - Features |nvim-scissors-nvim-scissors--features| 9 | - Rationale |nvim-scissors-nvim-scissors--rationale| 10 | - Requirements |nvim-scissors-nvim-scissors--requirements| 11 | - Installation |nvim-scissors-nvim-scissors--installation| 12 | - Usage |nvim-scissors-nvim-scissors--usage| 13 | - Configuration |nvim-scissors-nvim-scissors--configuration| 14 | - Cookbook & FAQ |nvim-scissors-nvim-scissors--cookbook-&-faq| 15 | - About the author |nvim-scissors-nvim-scissors--about-the-author| 16 | 17 | ============================================================================== 18 | 1. nvim-scissors *nvim-scissors-nvim-scissors-* 19 | 20 | 21 | 22 | Automagicalediting and creation of snippets. 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | TABLE OF CONTENTS *nvim-scissors-nvim-scissors--table-of-contents* 32 | 33 | - |nvim-scissors-features| 34 | - |nvim-scissors-rationale| 35 | - |nvim-scissors-requirements| 36 | - |nvim-scissors-installation| 37 | - |nvim-scissors-nvim-scissors| 38 | - |nvim-scissors-snippet-engine-setup| 39 | - |nvim-scissors-luasnip| 40 | - |nvim-scissors-mini.snippets| 41 | - |nvim-scissors-blink.cmp| 42 | - |nvim-scissors-basics-language-server| 43 | - |nvim-scissors-nvim-snippets| 44 | - |nvim-scissors-vim-vsnip| 45 | - |nvim-scissors-yasp.nvim| 46 | - |nvim-scissors-usage| 47 | - |nvim-scissors-starting-`nvim-scissors`| 48 | - |nvim-scissors-editing-snippets-in-the-popup-window| 49 | - |nvim-scissors-configuration| 50 | - |nvim-scissors-cookbook-&-faq| 51 | - |nvim-scissors-introduction-to-the-vscode-style-snippet-format| 52 | - |nvim-scissors-tabstops-and-variables| 53 | - |nvim-scissors-friendly-snippets| 54 | - |nvim-scissors-edit-snippet-title-or-description| 55 | - |nvim-scissors-version-controlling-snippets-&-snippet-file-formatting| 56 | - |nvim-scissors-snippets-on-visual-selections-(`luasnip`-only)| 57 | - |nvim-scissors-auto-triggered-snippets-(`luasnip`-only)| 58 | - |nvim-scissors-about-the-author| 59 | 60 | 61 | FEATURES *nvim-scissors-nvim-scissors--features* 62 | 63 | - Add new snippets, edit snippets, or delete snippets on the fly. 64 | - Syntax highlighting while you edit the snippet. Includes highlighting of 65 | tabstops and placeholders such as `$0`, `${2:foobar}`, or `$CLIPBOARD` 66 | - Automagical conversion from buffer text to JSON string. 67 | - Intuitive UI for editing the snippet, dynamically adapting the number of 68 | prefixes. 69 | - Automatic hot-reloading of any changes, so you do not have to restart nvim for 70 | changes to take effect. 71 | - Optional JSON-formatting and sorting of the snippet file. (|nvim-scissors-useful-when-version-controlling-your-snippet-collection|.) 72 | - Snippet/file selection via `telescope`, `snacks`, or `vim.ui.select`. 73 | - Automatic bootstrapping of the snippet folder or new snippet files if needed. 74 | - Supports only VSCode-style 75 | snippets . 76 | 77 | 78 | [!TIP] You can use snippet-converter.nvim 79 | to convert your snippets to 80 | the VSCode format. 81 | 82 | RATIONALE *nvim-scissors-nvim-scissors--rationale* 83 | 84 | - The VSCode snippet 85 | format is the 86 | closest thing to a standard regarding snippets. It is used by 87 | friendly-snippets and 88 | supported by most snippet engine plugins for nvim. 89 | - However, VSCode snippets are stored as JSON, which are a pain to modify 90 | manually. This plugin alleviates that pain by automagically writing the JSON 91 | for you. 92 | 93 | 94 | REQUIREMENTS *nvim-scissors-nvim-scissors--requirements* 95 | 96 | - nvim 0.10+ 97 | - Snippets saved in the |nvim-scissors-vscode-style-snippet-format|. 98 | - _Recommended_telescope 99 | OR snacks.nvim . Without one of them, 100 | the plugin falls back to `vim.ui.select`, which still works but lacks search 101 | and snippet previews. 102 | - A snippet engine that can load VSCode-style snippets, such as: 103 | - LuaSnip 104 | - mini.snippets 105 | - blink.cmp 106 | - basics-language-server 107 | - nvim-snippets 108 | - vim-vsnip 109 | - yasp.nvim 110 | - _Optional_Treesitter parsers for the languages you want syntax highlighting 111 | for. 112 | 113 | 114 | INSTALLATION *nvim-scissors-nvim-scissors--installation* 115 | 116 | 117 | NVIM-SCISSORS ~ 118 | 119 | >lua 120 | -- lazy.nvim 121 | { 122 | "chrisgrieser/nvim-scissors", 123 | dependencies = "nvim-telescope/telescope.nvim", -- if using telescope 124 | opts = { 125 | snippetDir = "path/to/your/snippetFolder", 126 | } 127 | }, 128 | 129 | -- packer 130 | use { 131 | "chrisgrieser/nvim-scissors", 132 | dependencies = "nvim-telescope/telescope.nvim", -- if using telescope 133 | config = function() 134 | require("scissors").setup ({ 135 | snippetDir = "path/to/your/snippetFolder", 136 | }) 137 | end, 138 | } 139 | < 140 | 141 | 142 | SNIPPET ENGINE SETUP ~ 143 | 144 | In addition, your snippet engine needs to point to the same snippet folder as 145 | `nvim-scissors` 146 | 147 | 148 | [!TIP] `vim.fn.stdpath("config")`returns the path to your nvim config. 149 | 150 | LUASNIP 151 | 152 | >lua 153 | require("luasnip.loaders.from_vscode").lazy_load { 154 | paths = { "path/to/your/snippetFolder" }, 155 | } 156 | < 157 | 158 | 159 | MINI.SNIPPETS 160 | 161 | `mini.snippets` preferred snippet location is any `snippets/` directory in the 162 | `runtimepath`. For manually maintained snippets the best location is the user 163 | config directory, which requires the following `nvim-scissors` setup: 164 | 165 | >lua 166 | require("scissors").setup({ 167 | snippetDir = vim.fn.stdpath("config") .. "/snippets", 168 | }) 169 | < 170 | 171 | The `mini.snippets` setup requires explicit definition of loaders. Following 172 | its Quickstart 173 | 174 | guide should be enough to make it respect snippets from 'snippets/' directory 175 | inside user config. **Note**`nvim-scissors` works only with VSCode-style 176 | snippet files (not Lua files or JSON arrays), and requires a 177 | |nvim-scissors-`package.json`-for-the-vscode-format|. 178 | 179 | 180 | BLINK.CMP 181 | 182 | >lua 183 | require("blink.cmp").setup { 184 | sources = { 185 | providers = { 186 | snippets = { 187 | opts = { 188 | search_paths = { "path/to/your/snippetFolder" }, 189 | }, 190 | } 191 | } 192 | } 193 | } 194 | < 195 | 196 | It is recommended to use the latest release of `blink.cmp` for hot-reloading to 197 | work. 198 | 199 | 200 | BASICS-LANGUAGE-SERVER 201 | 202 | >lua 203 | require("lspconfig").basics_ls.setup({ 204 | settings = { 205 | snippet = { 206 | enable = true, 207 | sources = { "path/to/your/snippetFolder" } 208 | }, 209 | } 210 | }) 211 | < 212 | 213 | 214 | NVIM-SNIPPETS 215 | 216 | >lua 217 | require("nvim-snippets").setup { 218 | search_paths = { "path/to/your/snippetFolder" }, 219 | } 220 | < 221 | 222 | 223 | VIM-VSNIP 224 | 225 | >lua 226 | vim.g.vsnip_snippet_dir = "path/to/your/snippetFolder" 227 | -- OR 228 | vim.g.vsnip_snippet_dirs = { "path/to/your/snippetFolder" } 229 | < 230 | 231 | 232 | YASP.NVIM 233 | 234 | >lua 235 | require("yasp").setup { 236 | paths = { 237 | vim.fn.stdpath("config") .. "/snippets/package.json", 238 | }, 239 | descs = { "user snippets" }, 240 | } 241 | < 242 | 243 | 244 | USAGE *nvim-scissors-nvim-scissors--usage* 245 | 246 | 247 | STARTING NVIM-SCISSORS ~ 248 | 249 | The plugin provides two lua functions, `.addNewSnippet()` and `.editSnippet()` 250 | 251 | >lua 252 | vim.keymap.set( 253 | "n", 254 | "se", 255 | function() require("scissors").editSnippet() end, 256 | { desc = "Snippet: Edit" } 257 | ) 258 | 259 | -- when used in visual mode, prefills the selection as snippet body 260 | vim.keymap.set( 261 | { "n", "x" }, 262 | "sa", 263 | function() require("scissors").addNewSnippet() end, 264 | { desc = "Snippet: Add" } 265 | ) 266 | < 267 | 268 | Youcan also use `:ScissorsAddNewSnippet` and `:ScissorsEditSnippet` if you 269 | prefer ex commands. 270 | 271 | The `:ScissorsAddSnippet` ex command also accepts a range to prefill the 272 | snippet body (for example `:'<,'> ScissorsAddNewSnippet` or `:3 273 | ScissorsAddNewSnippet`). 274 | 275 | 276 | EDITING SNIPPETS IN THE POPUP WINDOW ~ 277 | 278 | The popup is just one window, so you can move between the prefix area and the 279 | body with `j` and `k` or any other movement command. ("Prefix" is how trigger 280 | words are referred to in the VSCode format.) 281 | 282 | Use `showHelp` (default keymap: `?`) to show a notification containing all 283 | keymaps. 284 | 285 | The popup intelligently adapts to changes in the prefix area: Each line 286 | represents one prefix, and creating or removing lines in that area thus changes 287 | the number of prefixes. 288 | 289 | 290 | 291 | 292 | CONFIGURATION *nvim-scissors-nvim-scissors--configuration* 293 | 294 | The `.setup()` call is optional. 295 | 296 | >lua 297 | -- default settings 298 | require("scissors").setup { 299 | snippetDir = vim.fn.stdpath("config") .. "/snippets", 300 | editSnippetPopup = { 301 | height = 0.4, -- relative to the window, between 0-1 302 | width = 0.6, 303 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 304 | keymaps = { 305 | -- if not mentioned otherwise, the keymaps apply to normal mode 306 | cancel = "q", 307 | saveChanges = "", -- alternatively, can also use `:w` 308 | goBackToSearch = "", 309 | deleteSnippet = "", 310 | duplicateSnippet = "", 311 | openInFile = "", 312 | insertNextPlaceholder = "", -- insert & normal mode 313 | showHelp = "?", 314 | }, 315 | }, 316 | 317 | snippetSelection = { 318 | picker = "auto", ---@type "auto"|"telescope"|"snacks"|"vim.ui.select" 319 | 320 | telescope = { 321 | -- By default, the query only searches snippet prefixes. Set this to 322 | -- `true` to also search the body of the snippets. 323 | alsoSearchSnippetBody = false, 324 | 325 | -- accepts the common telescope picker config 326 | opts = { 327 | layout_strategy = "horizontal", 328 | layout_config = { 329 | horizontal = { width = 0.9 }, 330 | preview_width = 0.6, 331 | }, 332 | }, 333 | }, 334 | 335 | -- `snacks` picker configurable via snacks config, 336 | -- see https://github.com/folke/snacks.nvim/blob/main/docs/picker.md 337 | }, 338 | 339 | -- `none` writes as a minified json file using `vim.encode.json`. 340 | -- `yq`/`jq` ensure formatted & sorted json files, which is relevant when 341 | -- you version control your snippets. To use a custom formatter, set to a 342 | -- list of strings, which will then be passed to `vim.system()`. 343 | -- TIP: `jq` is already pre-installed on newer versions of macOS. 344 | ---@type "yq"|"jq"|"none"|string[] 345 | jsonFormatter = "none", 346 | 347 | backdrop = { 348 | enabled = true, 349 | blend = 50, -- between 0-100 350 | }, 351 | icons = { 352 | scissors = "󰩫", 353 | }, 354 | } 355 | < 356 | 357 | 358 | COOKBOOK & FAQ *nvim-scissors-nvim-scissors--cookbook-&-faq* 359 | 360 | 361 | INTRODUCTION TO THE VSCODE-STYLE SNIPPET FORMAT ~ 362 | 363 | This plugin requires that you have a valid VSCode snippet folder. In addition 364 | to saving the snippets in the required JSON format, there must also be a 365 | `package.json` file at the root of the snippet folder, specifying which files 366 | should be used for which languages. 367 | 368 | Example file structure inside the `snippetDir` 369 | 370 | >txt 371 | . 372 | ├── package.json 373 | ├── python.json 374 | ├── project-specific 375 | │ └── nvim-lua.json 376 | ├── javascript.json 377 | └── allFiletypes.json 378 | < 379 | 380 | Example`package.json` 381 | 382 | >json 383 | { 384 | "contributes": { 385 | "snippets": [ 386 | { 387 | "language": "python", 388 | "path": "./python.json" 389 | }, 390 | { 391 | "language": "lua", 392 | "path": "./project-specific/nvim-lua.json" 393 | }, 394 | { 395 | "language": ["javascript", "typescript"], 396 | "path": "./javascript.json" 397 | }, 398 | { 399 | "language": "all", 400 | "path": "./allFiletypes.json" 401 | } 402 | ] 403 | }, 404 | "name": "my-snippets" 405 | } 406 | < 407 | 408 | 409 | [!NOTE] Thespecial filetype `all` enables the snippets globally, regardless of 410 | filetype. 411 | Example snippet file (here: `nvim-lua.json`): 412 | 413 | >json 414 | { 415 | "autocmd (Filetype)": { 416 | "body": [ 417 | "vim.api.nvim_create_autocmd(\"FileType\", {", 418 | "\tpattern = \"${1:ft}\",", 419 | "\tcallback = function()", 420 | "\t\t$0", 421 | "\tend,", 422 | "})" 423 | ], 424 | "prefix": "autocmd (Filetype)" 425 | }, 426 | "file exists": { 427 | "body": "local fileExists = vim.uv.fs_stat(\"${1:filepath}\") ~= nil", 428 | "prefix": "file exists" 429 | }, 430 | } 431 | < 432 | 433 | For details, read the official VSCode snippet documentation: - Snippet file 434 | specification - 435 | `package.json` specification 436 | - 437 | LuaSnip-specific additions to the format 438 | 439 | 440 | 441 | TABSTOPS AND VARIABLES ~ 442 | 443 | Tabstops 444 | are 445 | denoted by `$1`, `$2`, `$3`, etc., with `$0` being the last tabstop. They 446 | support placeholders such as `${1:foobar}`. 447 | 448 | 449 | [!NOTE] Due to the use of `$` in the snippet syntax, any _literal_ `$` needs to 450 | be escaped as `\$`. 451 | Furthermore, there are various variables you can use, such as `$TM_FILENAME` or 452 | `$LINE_COMMENT`. See here for a full list of variables 453 | . 454 | 455 | 456 | FRIENDLY-SNIPPETS ~ 457 | 458 | Even though the snippets from the friendly-snippets 459 | repository are written in the 460 | VSCode-style format, editing them directly is not supported. The reason being 461 | that any changes made would be overwritten as soon as the `friendly-snippets` 462 | repository is updated (which happens fairly regularly). Unfortunately, there is 463 | little `nvim-scissors` can do about that. 464 | 465 | What you can do, however, is to copy individual snippets files from the 466 | `friendly-snippets` repository into your own snippet folder, and edit them 467 | there. 468 | 469 | 470 | EDIT SNIPPET TITLE OR DESCRIPTION ~ 471 | 472 | `nvim-scissors` only allows to edit the snippet prefix and snippet body, to 473 | keep the UI as simple as possible. For the few cases where you need to edit a 474 | snippet’s title or description, you can use the `openInFile` keymap and edit 475 | them directly in the snippet file. 476 | 477 | 478 | VERSION CONTROLLING SNIPPETS & SNIPPET FILE FORMATTING ~ 479 | 480 | This plugin writes JSON files via `vim.encode.json()`. That method saves the 481 | file in minified form and does not have a deterministic order of dictionary 482 | keys. 483 | 484 | Both, minification and unstable key order, are a problem if you version-control 485 | your snippet collection. To solve this issue, `nvim-scissors` lets you 486 | optionally unminify and sort the JSON files via `yq` or `jq` after updating a 487 | snippet. (Both are also available via mason.nvim 488 | .) 489 | 490 | It is recommended to run `yq`/`jq` once on all files in your snippet 491 | collection, since the first time you edit a file, you would still get a large 492 | diff from the initial sorting. You can do so with `yq` using this command: 493 | 494 | >bash 495 | cd "/your/snippet/dir" 496 | find . -name "*.json" | xargs -I {} yq --inplace --output-format=json "sort_keys(..)" {} 497 | < 498 | 499 | How to do the same with `jq` is left as an exercise to the reader. 500 | 501 | 502 | SNIPPETS ON VISUAL SELECTIONS (LUASNIP ONLY) ~ 503 | 504 | With `Luasnip`, this is an opt-in feature, enabled via: 505 | 506 | >lua 507 | require("luasnip").setup { 508 | store_selection_keys = "", 509 | } 510 | < 511 | 512 | In your VSCode-style snippet, use the token `$TM_SELECTED_TEXT` at the location 513 | where you want the selection to be inserted. (It’s roughly the equivalent of 514 | `LS_SELECT_RAW` in the `Luasnip` syntax.) 515 | 516 | Then, in visual mode, press the key from `store_selection_keys`. The selection 517 | disappears, and you are put in insert mode. The next snippet you now trigger is 518 | going to have `$TM_SELECTED_TEXT` replaced with your selection. 519 | 520 | 521 | AUTO-TRIGGERED SNIPPETS (LUASNIP ONLY) ~ 522 | 523 | While the VSCode snippet format does not support auto-triggered snippets, 524 | `LuaSnip` allows you to specify auto-triggering in the VSCode-style JSON files 525 | by adding the `luasnip` key 526 | . 527 | 528 | `nvim-scissors` does not touch any keys other than `prefix` and `body` in the 529 | JSON files, so any additions like the `luasnip` key are preserved. 530 | 531 | 532 | [!TIP] You can use the `openInFile` keymap to directory open JSON file at the 533 | snippet’s location to make edits there easier. 534 | 535 | ABOUT THE AUTHOR *nvim-scissors-nvim-scissors--about-the-author* 536 | 537 | In my day job, I am a sociologist studying the social mechanisms underlying the 538 | digital economy. For my PhD project, I investigate the governance of the app 539 | economy and how software ecosystems manage the tension between innovation and 540 | compatibility. If you are interested in this subject, feel free to get in 541 | touch. 542 | 543 | - Website 544 | - Mastodon 545 | - ResearchGate 546 | - LinkedIn 547 | 548 | 549 | 550 | Generated by panvimdoc 551 | 552 | vim:tw=78:ts=8:noet:ft=help:norl: 553 | -------------------------------------------------------------------------------- /lua/scissors/1-prepare-selection.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local convert = require("scissors.vscode-format.convert-object") 4 | local u = require("scissors.utils") 5 | local vb = require("scissors.vscode-format.validate-bootstrap") 6 | -------------------------------------------------------------------------------- 7 | 8 | ---@param lines string[] 9 | ---@return string[] dedentedLines 10 | ---@nodiscard 11 | local function dedentAndTrimBlanks(lines) 12 | -- remove leading and trailing blank lines 13 | while vim.trim(lines[1]) == "" do 14 | table.remove(lines, 1) 15 | end 16 | while vim.trim(lines[#lines]) == "" do 17 | table.remove(lines) 18 | end 19 | 20 | local smallestIndent = vim.iter(lines):fold(math.huge, function(acc, line) 21 | local indent = #line:match("^%s*") 22 | return math.min(acc, indent) 23 | end) 24 | local dedentedLines = vim.tbl_map(function(line) return line:sub(smallestIndent + 1) end, lines) 25 | return dedentedLines 26 | end 27 | 28 | -------------------------------------------------------------------------------- 29 | 30 | function M.editSnippet() 31 | local snippetDir = require("scissors.config").config.snippetDir 32 | 33 | -- GUARD 34 | if not vb.validate(snippetDir) then return end 35 | local packageJsonExist = u.fileExists(snippetDir .. "/package.json") 36 | if not packageJsonExist then 37 | u.notify( 38 | "Your snippet directory is missing a `package.json`.\n" 39 | .. "The file can be bootstrapped by adding a new snippet via:\n" 40 | .. ":ScissorsAddNewSnippet", 41 | "warn" 42 | ) 43 | return 44 | end 45 | 46 | -- GET ALL SNIPPETS 47 | local bufferFt = vim.bo.filetype 48 | local allSnippets = {} ---@type Scissors.SnippetObj[] 49 | for _, absPath in pairs(convert.getSnippetfilePathsForFt(bufferFt)) do 50 | local filetypeSnippets = convert.readVscodeSnippetFile(absPath, bufferFt) 51 | vim.list_extend(allSnippets, filetypeSnippets) 52 | end 53 | for _, absPath in pairs(convert.getSnippetfilePathsForFt("all")) do 54 | local globalSnippets = convert.readVscodeSnippetFile(absPath, "plaintext") 55 | vim.list_extend(allSnippets, globalSnippets) 56 | end 57 | 58 | -- GUARD 59 | if #allSnippets == 0 then 60 | u.notify("No snippets found for filetype: " .. bufferFt, "warn") 61 | return 62 | end 63 | 64 | -- SELECT 65 | require("scissors.2-picker.picker-choice").selectSnippet(allSnippets) 66 | end 67 | 68 | function M.addNewSnippet(exCmdArgs) 69 | local snippetDir = require("scissors.config").config.snippetDir 70 | 71 | -- GUARD & bootstrap 72 | if not vb.validate(snippetDir) then return end 73 | vb.bootstrapSnipDirIfNeeded(snippetDir) 74 | 75 | -- PARAMS 76 | local bufferFt = vim.bo.filetype 77 | exCmdArgs = exCmdArgs or {} 78 | 79 | -- VISUAL MODE: prefill body with selected text 80 | local bodyPrefill = { "" } 81 | local mode = vim.fn.mode() 82 | local calledFromVisualMode = mode:find("[vV]") 83 | local calledFromExCmd = exCmdArgs.range and exCmdArgs.range > 0 84 | if calledFromVisualMode then 85 | vim.cmd.normal { mode, bang = true } -- leave visual mode so `<`/`>` marks are set 86 | local startRow, startCol = unpack(vim.api.nvim_buf_get_mark(0, "<")) 87 | local endRow, endCol = unpack(vim.api.nvim_buf_get_mark(0, ">")) 88 | endCol = mode:find("V") and -1 or (endCol + 1) 89 | bodyPrefill = vim.api.nvim_buf_get_text(0, startRow - 1, startCol, endRow - 1, endCol, {}) 90 | elseif calledFromExCmd then 91 | bodyPrefill = 92 | vim.api.nvim_buf_get_text(0, exCmdArgs.line1 - 1, 0, exCmdArgs.line2 - 1, -1, {}) 93 | end 94 | if calledFromExCmd or calledFromVisualMode then 95 | bodyPrefill = dedentAndTrimBlanks(bodyPrefill) 96 | -- escape `$` 97 | bodyPrefill = vim.tbl_map(function(line) return line:gsub("%$", "\\$") end, bodyPrefill) 98 | end 99 | 100 | -- GET LIST OF ALL SNIPPET FILES WITH MATCHING FILETYPE 101 | local snipFilesForFt = vim.tbl_map( 102 | function(file) return { path = file, ft = bufferFt } end, 103 | convert.getSnippetfilePathsForFt(bufferFt) 104 | ) 105 | local snipFilesForAll = vim.tbl_map( 106 | function(file) return { path = file, ft = "plaintext" } end, 107 | convert.getSnippetfilePathsForFt("all") 108 | ) 109 | ---@type Scissors.snipFile[] 110 | local allSnipFiles = vim.list_extend(snipFilesForFt, snipFilesForAll) 111 | 112 | -- GUARD file listed in `package.json` does not exist 113 | for _, snipFile in ipairs(allSnipFiles) do 114 | if not u.fileExists(snipFile.path) then 115 | local relPath = snipFile.path:sub(#snippetDir + 1) 116 | local msg = ("%q is listed as a file in the `package.json` "):format(relPath) 117 | .. "but it does not exist. Aborting." 118 | u.notify(msg, "error") 119 | return 120 | end 121 | end 122 | 123 | -- BOOTSTRAP new snippet file, if none exists 124 | if #allSnipFiles == 0 then 125 | u.notify(("No snippet file found for filetype: %s.\nBootstrapping one."):format(bufferFt)) 126 | local newSnipFile = vb.bootstrapSnippetFile(bufferFt) 127 | table.insert(allSnipFiles, newSnipFile) 128 | end 129 | 130 | -- SELECT 131 | if #allSnipFiles == 1 then 132 | require("scissors.3-edit-popup").createNewSnipAndEdit(allSnipFiles[1], bodyPrefill) 133 | else 134 | require("scissors.2-picker.vim-ui-select").addSnippet(allSnipFiles, bodyPrefill) 135 | end 136 | end 137 | 138 | -------------------------------------------------------------------------------- 139 | return M 140 | -------------------------------------------------------------------------------- /lua/scissors/2-picker/picker-choice.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@param allSnippets Scissors.SnippetObj[] 5 | function M.selectSnippet(allSnippets) 6 | local icon = require("scissors.config").config.icons.scissors 7 | local prompt = vim.trim(icon .. " Select snippet: ") 8 | local picker = require("scissors.config").config.snippetSelection.picker 9 | local hasTelescope, _ = pcall(require, "telescope") 10 | local hasSnacks, _ = pcall(require, "snacks") 11 | 12 | if picker == "telescope" or (picker == "auto" and hasTelescope) then 13 | require("scissors.2-picker.telescope").selectSnippet(allSnippets, prompt) 14 | elseif picker == "snacks" or (picker == "auto" and hasSnacks) then 15 | require("scissors.2-picker.snacks").selectSnippet(allSnippets, prompt) 16 | else 17 | require("scissors.2-picker.vim-ui-select").selectSnippet(allSnippets, prompt) 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- 22 | return M 23 | -------------------------------------------------------------------------------- /lua/scissors/2-picker/snacks.lua: -------------------------------------------------------------------------------- 1 | -- DOCS https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#-module 2 | -------------------------------------------------------------------------------- 3 | local M = {} 4 | 5 | local u = require("scissors.utils") 6 | -------------------------------------------------------------------------------- 7 | 8 | ---@param snippets Scissors.SnippetObj[] 9 | ---@return Scissors.SnacksObj[] 10 | local function createSnacksItems(snippets) 11 | ---@type Scissors.SnacksObj[] 12 | local items = {} 13 | for i, snip in ipairs(snippets) do 14 | local filename = vim.fs.basename(snip.fullPath):gsub("%.json$", "") 15 | local displayName = u.snipDisplayName(snip) 16 | local name = displayName .. "\t" .. filename 17 | 18 | table.insert(items, { 19 | idx = i, 20 | score = i, 21 | text = displayName .. " " .. table.concat(snip.body, "\n"), 22 | name = name, 23 | snippet = snip, 24 | displayName = displayName, 25 | }) 26 | end 27 | 28 | return items 29 | end 30 | 31 | ---@param snippets Scissors.SnippetObj[] entries 32 | ---@param prompt string 33 | function M.selectSnippet(snippets, prompt) 34 | return require("snacks").picker { 35 | title = prompt:gsub(": ?", ""), 36 | items = createSnacksItems(snippets), 37 | 38 | format = function(item, _) ---@param item Scissors.SnacksObj 39 | return { 40 | { item.displayName, "SnacksPickerFile" }, 41 | { " " }, 42 | { item.snippet.filetype, "Comment" }, 43 | } 44 | end, 45 | 46 | preview = function(ctx) 47 | local snip = ctx.item.snippet ---@type Scissors.SnippetObj 48 | local bufnr = ctx.buf ---@type number 49 | 50 | vim.bo[bufnr].modifiable = true 51 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, snip.body) 52 | vim.bo[bufnr].modifiable = false 53 | 54 | vim.bo[bufnr].filetype = snip.filetype 55 | vim.defer_fn(function() u.tokenHighlight(bufnr) end, 1) 56 | end, 57 | 58 | ---@param item Scissors.SnacksObj, 59 | confirm = function(picker, item) 60 | picker:close() 61 | require("scissors.3-edit-popup").editInPopup(item.snippet, "update") 62 | end, 63 | } 64 | end 65 | 66 | -------------------------------------------------------------------------------- 67 | return M 68 | -------------------------------------------------------------------------------- /lua/scissors/2-picker/telescope.lua: -------------------------------------------------------------------------------- 1 | -- DOCS https://github.com/nvim-telescope/telescope.nvim/blob/master/developers.md 2 | -------------------------------------------------------------------------------- 3 | local M = {} 4 | 5 | local pickers = require("telescope.pickers") 6 | local telescopeConf = require("telescope.config").values 7 | local actionState = require("telescope.actions.state") 8 | local actions = require("telescope.actions") 9 | local finders = require("telescope.finders") 10 | local previewers = require("telescope.previewers") 11 | 12 | local u = require("scissors.utils") 13 | -------------------------------------------------------------------------------- 14 | 15 | ---@param snippets Scissors.SnippetObj[] entries 16 | ---@param prompt string 17 | function M.selectSnippet(snippets, prompt) 18 | require("scissors.backdrop").setup("TelescopeResults") 19 | local conf = require("scissors.config").config.snippetSelection.telescope 20 | 21 | pickers 22 | .new(conf.opts, { 23 | prompt_title = prompt:gsub(": ?$", ""), 24 | sorter = telescopeConf.generic_sorter(conf.opts), 25 | 26 | finder = finders.new_table { 27 | results = snippets, 28 | entry_maker = function(snip) 29 | local matcher = table.concat(snip.prefix, " ") 30 | if conf.alsoMatchBody then 31 | matcher = matcher .. " " .. table.concat(snip.body, "\n") 32 | end 33 | return { 34 | value = snip, 35 | display = function(entry) 36 | local _snip = entry.value 37 | local filename = vim.fs.basename(snip.fullPath):gsub("%.json$", "") 38 | local out = u.snipDisplayName(_snip) .. "\t" .. filename 39 | local highlights = { 40 | { { #out - #filename, #out }, "TelescopeResultsComment" }, 41 | } 42 | return out, highlights 43 | end, 44 | ordinal = matcher, 45 | } 46 | end, 47 | }, 48 | 49 | -- DOCS `:help telescope.previewers` 50 | previewer = previewers.new_buffer_previewer { 51 | dyn_title = function(_, entry) 52 | local snip = entry.value 53 | return u.snipDisplayName(snip) 54 | end, 55 | define_preview = function(self, entry) 56 | local snip = entry.value 57 | local bufnr = self.state.bufnr 58 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, snip.body) 59 | 60 | -- highlights of the snippet 61 | vim.bo[bufnr].filetype = snip.filetype 62 | vim.defer_fn(function() u.tokenHighlight(bufnr) end, 1) 63 | end, 64 | }, 65 | 66 | attach_mappings = function(promptBufnr, _) 67 | actions.select_default:replace(function() 68 | actions.close(promptBufnr) 69 | local snip = actionState.get_selected_entry().value ---@type Scissors.SnippetObj 70 | require("scissors.3-edit-popup").editInPopup(snip, "update") 71 | end) 72 | return true -- `true` = keeps default mappings from user 73 | end, 74 | }) 75 | :find() 76 | end 77 | 78 | -------------------------------------------------------------------------------- 79 | return M 80 | -------------------------------------------------------------------------------- /lua/scissors/2-picker/vim-ui-select.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local edit = require("scissors.3-edit-popup") 4 | local u = require("scissors.utils") 5 | -------------------------------------------------------------------------------- 6 | 7 | ---@param snippets Scissors.SnippetObj[] entries 8 | ---@param prompt string 9 | function M.selectSnippet(snippets, prompt) 10 | vim.ui.select(snippets, { 11 | prompt = prompt, 12 | format_item = function(snip) 13 | local filename = vim.fs.basename(snip.fullPath):gsub("%.json$", "") 14 | return u.snipDisplayName(snip) .. " [" .. filename .. "]" 15 | end, 16 | }, function(snip) 17 | if not snip then return end 18 | edit.editInPopup(snip, "update") 19 | end) 20 | end 21 | 22 | -------------------------------------------------------------------------------- 23 | 24 | ---@param allSnipFiles Scissors.snipFile[] 25 | ---@param bodyPrefill string[] for the new snippet 26 | function M.addSnippet(allSnipFiles, bodyPrefill) 27 | local icon = require("scissors.config").config.icons.scissors 28 | local snippetDir = require("scissors.config").config.snippetDir 29 | 30 | vim.ui.select(allSnipFiles, { 31 | prompt = vim.trim(icon .. " Select file for new snippet: "), 32 | format_item = function(item) 33 | local relPath = item.path:sub(#snippetDir + 2) 34 | return relPath:gsub("%.jsonc?$", "") 35 | end, 36 | }, function(snipFile) 37 | if not snipFile then return end 38 | edit.createNewSnipAndEdit(snipFile, bodyPrefill) 39 | end) 40 | end 41 | -------------------------------------------------------------------------------- 42 | return M 43 | -------------------------------------------------------------------------------- /lua/scissors/3-edit-popup.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local convert = require("scissors.vscode-format.convert-object") 4 | local rw = require("scissors.vscode-format.read-write") 5 | local u = require("scissors.utils") 6 | -------------------------------------------------------------------------------- 7 | 8 | ---@class (exact) Scissors.extMarkInfo 9 | ---@field bufnr number 10 | ---@field ns number 11 | ---@field id number 12 | 13 | ---INFO the extmark representing the horizontal divider between prefix and body 14 | ---also acts as method to determine the number of prefixes. If the user has 15 | ---inserted/deleted a line, this is considered a change in number of prefixes 16 | ---@param prefixBodySep Scissors.extMarkInfo 17 | ---@return number newCount 18 | ---@nodiscard 19 | local function getPrefixCount(prefixBodySep) 20 | local extM = prefixBodySep 21 | local newCount = vim.api.nvim_buf_get_extmark_by_id(extM.bufnr, extM.ns, extM.id, {})[1] + 1 22 | return newCount 23 | end 24 | 25 | -- continuously update highlight prefix lines and add label 26 | ---@param newPrefixCount number 27 | ---@param bufnr number 28 | local function updatePrefixLabel(newPrefixCount, bufnr) 29 | local prefixLabelNs = vim.api.nvim_create_namespace("nvim-scissors-prefix-label") 30 | vim.api.nvim_buf_clear_namespace(bufnr, prefixLabelNs, 0, -1) 31 | for i = 1, newPrefixCount do 32 | local label = newPrefixCount == 1 and "Prefix" or "Prefix #" .. i 33 | vim.api.nvim_buf_set_extmark(bufnr, prefixLabelNs, i - 1, 0, { 34 | virt_text = { { label, "Todo" } }, 35 | virt_text_pos = "right_align", 36 | line_hl_group = "DiagnosticVirtualTextHint", 37 | }) 38 | end 39 | end 40 | 41 | ---@param bufnr number 42 | ---@param winnr number 43 | ---@param mode "new"|"update" 44 | ---@param snip Scissors.SnippetObj 45 | ---@param prefixBodySep Scissors.extMarkInfo 46 | local function setupPopupKeymaps(bufnr, winnr, mode, snip, prefixBodySep) 47 | local maps = require("scissors.config").config.editSnippetPopup.keymaps 48 | local function keymap(modes, lhs, rhs) 49 | vim.keymap.set(modes, lhs, rhs, { buffer = bufnr, nowait = true, silent = true }) 50 | end 51 | local function closePopup() 52 | if vim.api.nvim_win_is_valid(winnr) then vim.api.nvim_win_close(winnr, true) end 53 | if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_delete(bufnr, { force = true }) end 54 | end 55 | local function confirmChanges() 56 | local editedLines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 57 | local newPrefixCount = getPrefixCount(prefixBodySep) 58 | 59 | -- VALIDATE 60 | local prefixEmpty = vim.trim(vim.iter(editedLines):take(newPrefixCount):join("\n")) == "" 61 | if prefixEmpty then 62 | u.notify("Prefix cannot be empty.", "warn") 63 | return 64 | end 65 | local bodyEmpty = vim.trim(vim.iter(editedLines):skip(newPrefixCount):join("\n")) == "" 66 | if bodyEmpty then 67 | u.notify("Body cannot be empty.", "warn") 68 | return 69 | end 70 | 71 | convert.updateSnippetInVscodeSnippetFile(snip, editedLines, newPrefixCount) 72 | closePopup() 73 | end 74 | 75 | keymap("n", maps.cancel, closePopup) 76 | 77 | -- also close the popup on leaving buffer, ensures there is not leftover 78 | -- buffer when user closes popup in a different way, such as `:close`. 79 | vim.api.nvim_create_autocmd("BufLeave", { 80 | buffer = bufnr, 81 | once = true, 82 | callback = closePopup, 83 | }) 84 | 85 | keymap("n", maps.saveChanges, confirmChanges) 86 | -- so people in the habit of saving via `:w` do not get an error 87 | vim.cmd.cnoreabbrev(" w ScissorsSave") 88 | vim.cmd.cnoreabbrev(" write ScissorsSave") 89 | vim.api.nvim_buf_create_user_command(bufnr, "ScissorsSave", confirmChanges, {}) 90 | 91 | keymap("n", maps.deleteSnippet, function() 92 | if mode == "new" then 93 | u.notify("Cannot delete a snippet that has not been saved yet.", "warn") 94 | return 95 | end 96 | rw.deleteSnippet(snip) 97 | closePopup() 98 | end) 99 | 100 | keymap("n", maps.duplicateSnippet, function() 101 | if mode == "new" then 102 | u.notify("Cannot duplicate a snippet that has not been saved yet.", "warn") 103 | return 104 | end 105 | u.notify(("Duplicating snippet %q"):format(u.snipDisplayName(snip))) 106 | local currentBody = 107 | vim.api.nvim_buf_get_lines(bufnr, getPrefixCount(prefixBodySep), -1, false) 108 | closePopup() 109 | local snipFile = { path = snip.fullPath, ft = snip.filetype } ---@type Scissors.snipFile 110 | M.createNewSnipAndEdit(snipFile, currentBody) 111 | end) 112 | 113 | keymap("n", maps.openInFile, function() 114 | closePopup() 115 | -- since there seem to be various escaping issues, simply using `.` to 116 | -- match any char instead, since a rare wrong location is preferable to 117 | -- the opening failing 118 | local locationInFile = snip.originalKey:gsub("[/()%[%] ]", ".") 119 | vim.cmd(("edit +/%q: %s"):format(locationInFile, snip.fullPath)) 120 | end) 121 | 122 | keymap({ "n", "i" }, maps.insertNextPlaceholder, function() 123 | local bufText = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") 124 | local numbers = {} 125 | local placeholderPattern = "${?(%d+)" -- match `$1`, `${2:word}`, or `${3|word|}` 126 | for placeholder in bufText:gmatch(placeholderPattern) do 127 | table.insert(numbers, tonumber(placeholder)) 128 | end 129 | local highestPlaceholder = #numbers > 0 and math.max(unpack(numbers)) or 0 130 | 131 | local insertStr = ("${%s:}"):format(highestPlaceholder + 1) 132 | local row, col = unpack(vim.api.nvim_win_get_cursor(0)) 133 | vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { insertStr }) 134 | 135 | -- move cursor 136 | vim.api.nvim_win_set_cursor(0, { row, col + #insertStr - 1 }) 137 | vim.cmd.startinsert() 138 | end) 139 | 140 | keymap("n", maps.goBackToSearch, function() 141 | closePopup() 142 | if mode == "new" then 143 | require("scissors").addNewSnippet() 144 | elseif mode == "update" then 145 | require("scissors").editSnippet() 146 | end 147 | end) 148 | 149 | keymap("n", maps.showHelp, function() 150 | local info = { 151 | "The popup is just one window, so you can move between the prefix area " 152 | .. "and the body with `j` and `k` or any other movement command.", 153 | "", 154 | "The popup intelligently adapts to changes in the prefix area: Each line represents " 155 | .. "one prefix, and creating or removing lines in that area thus changes the number of prefixes.", 156 | "", 157 | ("- [%s] cancel"):format(maps.cancel), 158 | ("- [%s] save changes"):format(maps.saveChanges), 159 | ("- [%s] go back to search"):format(maps.goBackToSearch), 160 | ("- [%s] delete snippet"):format(maps.deleteSnippet), 161 | ("- [%s] duplicate snippet"):format(maps.duplicateSnippet), 162 | ("- [%s] open in file"):format(maps.openInFile), 163 | ("- [%s] insert next placeholder (normal & insert)"):format(maps.insertNextPlaceholder), 164 | ("- [%s] show help"):format(maps.showHelp), 165 | "", 166 | "All mappings apply to normal mode (if not noted otherwise).", 167 | } 168 | u.notify(table.concat(info, "\n"), "info", { id = "scissors-help", timeout = 10000 }) 169 | end) 170 | 171 | ----------------------------------------------------------------------------- 172 | 173 | -- HACK deal with deletion and creation of prefixes on the last line (see #6) 174 | local function normal(cmd) vim.cmd.normal { cmd, bang = true } end 175 | 176 | keymap("n", "dd", function() 177 | local prefixCount = getPrefixCount(prefixBodySep) 178 | local onLastPrefixLine = prefixCount == vim.api.nvim_win_get_cursor(0)[1] 179 | normal(onLastPrefixLine and "^DkJ" or "dd") 180 | end) 181 | 182 | keymap("n", "o", function() 183 | local prefixCount = getPrefixCount(prefixBodySep) 184 | local onLastPrefixLine = prefixCount == vim.api.nvim_win_get_cursor(0)[1] 185 | local hasBodyLines = prefixCount < vim.api.nvim_buf_line_count(0) 186 | if onLastPrefixLine and hasBodyLines then 187 | local currentLine = vim.api.nvim_get_current_line() 188 | vim.api.nvim_buf_set_lines(0, prefixCount - 1, prefixCount - 1, false, { currentLine }) 189 | normal("cc") 190 | else 191 | normal("o") 192 | end 193 | vim.cmd.startinsert() 194 | end) 195 | end 196 | 197 | -------------------------------------------------------------------------------- 198 | 199 | ---@param snipFile Scissors.snipFile 200 | ---@param bodyPrefill string[] 201 | function M.createNewSnipAndEdit(snipFile, bodyPrefill) 202 | ---@type Scissors.SnippetObj 203 | local snip = { 204 | prefix = { "" }, 205 | body = bodyPrefill, 206 | fullPath = snipFile.path, 207 | filetype = snipFile.ft, 208 | fileIsNew = snipFile.fileIsNew, 209 | } 210 | M.editInPopup(snip, "new") 211 | end 212 | 213 | ---@param snip Scissors.SnippetObj 214 | ---@param mode "new"|"update" 215 | function M.editInPopup(snip, mode) 216 | local conf = require("scissors.config").config.editSnippetPopup 217 | local ns = vim.api.nvim_create_namespace("nvim-scissors-editing") 218 | 219 | -- CREATE BUFFER 220 | local bufnr = vim.api.nvim_create_buf(false, true) 221 | 222 | local temp = vim.deepcopy(snip.prefix) -- copy since `list_extend` mutates destination 223 | local lines = vim.list_extend(temp, snip.body) 224 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 225 | 226 | local bufName = mode == "new" and "New snippet" 227 | or ("Edit snippet %q"):format(u.snipDisplayName(snip)) 228 | vim.api.nvim_buf_set_name(bufnr, bufName) 229 | 230 | -- prefer only starting treesitter as opposed to setting the buffer filetype, 231 | -- as this avoid triggering the filetype plugin, which can sometimes entail 232 | -- undesired effects like LSPs attaching 233 | local ft = snip.filetype 234 | if ft == "zsh" or ft == "sh" then ft = "bash" end -- substitute missing `sh` and `zsh` parsers 235 | pcall(vim.treesitter.start, bufnr, ft) -- errors when no parser available 236 | vim.bo[bufnr].filetype = require("scissors.config").scissorsFiletype 237 | 238 | -- WINDOW TITLE 239 | local icon = require("scissors.config").config.icons.scissors 240 | local nameOfSnippetFile = vim.fs.basename(snip.fullPath) 241 | local winTitle = { 242 | { " " .. vim.trim(icon .. " " .. bufName) .. " ", "FloatTitle" }, 243 | { " " .. nameOfSnippetFile .. " ", "Comment" }, 244 | } 245 | 246 | -- FOOTER – KEYMAP HINTS 247 | local hlgroup = { key = "Comment", desc = "NonText" } 248 | local maps = require("scissors.config").config.editSnippetPopup.keymaps 249 | local footer = { 250 | { " normal mode: " }, 251 | { maps.showHelp:gsub("[<>]", ""), hlgroup.key }, 252 | { " help", hlgroup.desc }, 253 | { " " }, 254 | { maps.saveChanges:gsub("[<>]", ""), hlgroup.key }, 255 | { " save", hlgroup.desc }, 256 | { " " }, 257 | { maps.cancel:gsub("[<>]", ""), hlgroup.key }, 258 | { " cancel", hlgroup.desc }, 259 | { " " }, 260 | { maps.insertNextPlaceholder:gsub("[<>]", ""), hlgroup.key }, 261 | { " placeholder (normal & insert)", hlgroup.desc }, 262 | { " " }, 263 | } 264 | 265 | -- CREATE WINDOW 266 | local popupZindex = 45 -- below nvim-notify, which uses 50 267 | local winnr = vim.api.nvim_open_win(bufnr, true, { 268 | -- centered window 269 | relative = "editor", 270 | width = math.floor(conf.width * vim.o.columns), 271 | height = math.floor(conf.height * vim.o.lines), 272 | row = math.floor((1 - conf.height) * vim.o.lines / 2), 273 | col = math.floor((1 - conf.width) * vim.o.columns / 2), 274 | 275 | title = winTitle, 276 | title_pos = "center", 277 | border = conf.border, 278 | zindex = popupZindex, 279 | footer = footer, 280 | }) 281 | vim.wo[winnr].signcolumn = "no" 282 | vim.wo[winnr].statuscolumn = " " -- just for padding 283 | vim.wo[winnr].winfixbuf = true 284 | vim.wo[winnr].conceallevel = 0 285 | -- reduce scrolloff based on user-set window size 286 | vim.wo[winnr].sidescrolloff = math.floor(vim.wo.sidescrolloff * conf.width) 287 | vim.wo[winnr].scrolloff = math.floor(vim.wo.scrolloff * conf.height) 288 | require("scissors.backdrop").new(bufnr, popupZindex) 289 | 290 | -- move cursor 291 | if mode == "new" then 292 | vim.defer_fn(vim.cmd.startinsert, 1) 293 | elseif mode == "update" then 294 | local firstLineOfBody = #snip.prefix + 1 295 | pcall(vim.api.nvim_win_set_cursor, winnr, { firstLineOfBody, 0 }) 296 | end 297 | 298 | -- PREFIX-BODY-SEPARATOR 299 | -- (INFO its position determines number of prefixes) 300 | 301 | -- style the separator in a way that it does not appear to be two windows, see https://github.com/chrisgrieser/nvim-scissors/issues/24#issuecomment-2561255043 302 | local separatorChar = "┄" 303 | local separatorHlgroup = "Comment" 304 | 305 | local winWidth = vim.api.nvim_win_get_width(winnr) 306 | local prefixBodySep = { bufnr = bufnr, ns = ns, id = -1 } ---@type Scissors.extMarkInfo 307 | prefixBodySep.id = vim.api.nvim_buf_set_extmark(bufnr, ns, #snip.prefix - 1, 0, { 308 | virt_lines = { 309 | { { (separatorChar):rep(winWidth), separatorHlgroup } }, 310 | }, 311 | virt_lines_leftcol = true, 312 | -- "above line n" instead of "below line n-1" changes where new lines 313 | -- occur when creating them. The latter appears to be more intuitive. 314 | virt_lines_above = false, 315 | }) 316 | 317 | -- PREFIX LABEL 318 | updatePrefixLabel(#snip.prefix, bufnr) -- initialize 319 | vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { 320 | buffer = bufnr, 321 | callback = function() 322 | local newPrefixCount = getPrefixCount(prefixBodySep) 323 | updatePrefixLabel(newPrefixCount, bufnr) 324 | end, 325 | }) 326 | 327 | -- Adjusts the popup size when the Neovim window is resized 328 | vim.api.nvim_create_autocmd("VimResized", { 329 | group = vim.api.nvim_create_augroup("scissors-resized", { clear = true }), 330 | callback = function() 331 | if not vim.api.nvim_win_is_valid(winnr) then return end 332 | 333 | vim.api.nvim_win_set_config(winnr, { 334 | relative = "editor", 335 | width = math.floor(conf.width * vim.o.columns), 336 | height = math.floor(conf.height * vim.o.lines), 337 | row = math.floor((1 - conf.height) * vim.o.lines / 2), 338 | col = math.floor((1 - conf.width) * vim.o.columns / 2), 339 | }) 340 | end, 341 | }) 342 | 343 | -- MISC 344 | setupPopupKeymaps(bufnr, winnr, mode, snip, prefixBodySep) 345 | u.tokenHighlight(bufnr) 346 | end 347 | 348 | -------------------------------------------------------------------------------- 349 | return M 350 | -------------------------------------------------------------------------------- /lua/scissors/4-hot-reload.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("scissors.utils") 3 | -------------------------------------------------------------------------------- 4 | 5 | local hasNotifiedOnRestartRequirement = false 6 | 7 | ---@param path string 8 | ---@param fileIsNew? boolean 9 | function M.reloadSnippetFile(path, fileIsNew) 10 | local success = false 11 | ---@type string? 12 | local errorMsg = "" 13 | 14 | local luasnipInstalled, luasnipLoaders = pcall(require, "luasnip.loaders") 15 | local nvimSnippetsInstalled, snippetUtils = pcall(require, "snippets.utils") 16 | local vimVsnipInstalled = vim.g.loaded_vsnip ~= nil -- https://github.com/hrsh7th/vim-vsnip/blob/master/plugin/vsnip.vim#L4C5-L4C17 17 | local blinkCmpInstalled, blinkCmp = pcall(require, "blink.cmp") 18 | local basicsLsInstalled = vim.fn.executable("basics-language-server") == 1 19 | local miniSnippetsInstalled = _G.MiniSnippets ~= nil ---@diagnostic disable-line:undefined-field 20 | -- INFO yasp.nvim does not need to be hot-reloaded, since it reloads every 21 | -- time on `BufEnter` https://github.com/DimitrisDimitropoulos/yasp.nvim/issues/2#issuecomment-2764463329 22 | 23 | ----------------------------------------------------------------------------- 24 | 25 | -- GUARD 26 | -- hot-reloading new files is supported by mini.snippets: https://github.com/chrisgrieser/nvim-scissors/pull/25#issuecomment-2561345395 27 | if fileIsNew and not miniSnippetsInstalled then 28 | local name = vim.fs.basename(path) 29 | local msg = ("%q is a new file and thus cannot be hot-reloaded. "):format(name) 30 | .. "Please restart nvim for this change to take effect." 31 | u.notify(msg) 32 | return 33 | end 34 | 35 | ----------------------------------------------------------------------------- 36 | -- https://github.com/L3MON4D3/LuaSnip/blob/master/DOC.md#loaders 37 | if luasnipInstalled then 38 | success, errorMsg = pcall(luasnipLoaders.reload_file, path) 39 | 40 | -- undocumented, https://github.com/garymjr/nvim-snippets/blob/main/lua/snippets/utils/init.lua#L161-L178 41 | elseif nvimSnippetsInstalled then 42 | success, errorMsg = pcall(snippetUtils.reload_file, path, true) 43 | 44 | -- https://github.com/hrsh7th/vim-vsnip/blob/02a8e79295c9733434aab4e0e2b8c4b7cea9f3a9/autoload/vsnip/source/vscode.vim#L7 45 | elseif vimVsnipInstalled then 46 | success, errorMsg = pcall(vim.fn["vsnip#source#vscode#refresh"], path) 47 | 48 | -- https://github.com/antonk52/basics-language-server/issues/1 49 | elseif basicsLsInstalled then 50 | local client = vim.lsp.get_clients({ name = "basics_ls" })[1] 51 | if client then 52 | success = true 53 | client:stop() 54 | vim.defer_fn(vim.cmd.edit, 1000) -- wait for shutdown -> reloads -> re-attach LSPs 55 | else 56 | success = false 57 | errorMsg = "`basics_ls` client not found." 58 | end 59 | 60 | -- https://github.com/Saghen/blink.cmp/issues/428#issuecomment-2513235377 61 | elseif blinkCmpInstalled then 62 | success, errorMsg = pcall(blinkCmp.reload, "snippets") 63 | 64 | -- contributed by @echasnovski themselves via #25 65 | elseif miniSnippetsInstalled then 66 | --- Reset whole cache so that next "prepare" step rereads file(s) 67 | _G.MiniSnippets.setup(_G.MiniSnippets.config) ---@diagnostic disable-line:undefined-field 68 | success = true 69 | 70 | ----------------------------------------------------------------------------- 71 | 72 | -- NOTIFY 73 | elseif not hasNotifiedOnRestartRequirement then 74 | local msg = 75 | "Your snippet plugin does not support hot-reloading. Restart nvim for changes to take effect." 76 | u.notify(msg, "info") 77 | hasNotifiedOnRestartRequirement = true 78 | return 79 | end 80 | 81 | if not success then 82 | local msg = ("Failed to hot-reload snippet file: %q\n\n."):format(errorMsg) 83 | .. "Please restart nvim for changes to snippets to take effect. " 84 | .. "If this issue keeps occurring, create a bug report at your snippet plugin's repo." 85 | u.notify(msg, "warn") 86 | end 87 | end 88 | 89 | -------------------------------------------------------------------------------- 90 | return M 91 | -------------------------------------------------------------------------------- /lua/scissors/backdrop.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | local backdropName = "ScissorsBackdrop" 5 | 6 | ---@param referenceBuf number Reference buffer, when that buffer is closed, the backdrop will be closed too 7 | ---@param referenceZindex? number zindex of the reference window, where the backdrop should be placed below 8 | function M.new(referenceBuf, referenceZindex) 9 | local config = require("scissors.config").config 10 | if not config.backdrop.enabled then return end 11 | local blend = config.backdrop.blend 12 | 13 | -- `DressingSelect` has a zindex of 150: https://github.com/stevearc/dressing.nvim/blob/e3714c8049b2243e792492c4149e4cc395c68eb9/lua/dressing/select/builtin.lua#L96 14 | -- `nivm-notify` and `Telescope` apparently do not set a zindex, so they use 15 | -- the default value of `nvim_open_win`, which is 50: https://neovim.io/doc/user/api.html#nvim_open_win() 16 | -- satellite.nvim has (by default) 40, backdrop should be above -- https://github.com/lewis6991/satellite.nvim?tab=readme-ov-file#usage 17 | if not referenceZindex then referenceZindex = 50 end 18 | 19 | local bufnr = vim.api.nvim_create_buf(false, true) 20 | local winnr = vim.api.nvim_open_win(bufnr, false, { 21 | relative = "editor", 22 | row = 0, 23 | col = 0, 24 | width = vim.o.columns, 25 | height = vim.o.lines, 26 | focusable = false, 27 | border = "none", 28 | style = "minimal", 29 | zindex = referenceZindex - 1, -- ensure it's below the reference window 30 | }) 31 | vim.api.nvim_set_hl(0, backdropName, { bg = "#000000", default = true }) 32 | vim.wo[winnr].winhighlight = "Normal:" .. backdropName 33 | vim.wo[winnr].winblend = blend 34 | vim.bo[bufnr].buftype = "nofile" 35 | vim.bo[bufnr].filetype = backdropName 36 | 37 | -- close backdrop when the reference buffer is closed 38 | vim.api.nvim_create_autocmd({ "WinClosed", "BufLeave" }, { 39 | group = vim.api.nvim_create_augroup(backdropName, { clear = true }), 40 | once = true, 41 | buffer = referenceBuf, 42 | callback = function() 43 | if vim.api.nvim_win_is_valid(winnr) then vim.api.nvim_win_close(winnr, true) end 44 | if vim.api.nvim_buf_is_valid(bufnr) then 45 | vim.api.nvim_buf_delete(bufnr, { force = true }) 46 | end 47 | end, 48 | }) 49 | end 50 | 51 | ---Sets up autocmd that creates a backdrop for the next occurrence of the given filetype 52 | ---@param filetype string 53 | ---@return integer augroup 54 | function M.setup(filetype) 55 | local group = vim.api.nvim_create_augroup("nvim-scissors.backdrop." .. filetype, {}) 56 | vim.api.nvim_create_autocmd("FileType", { 57 | group = group, 58 | once = true, 59 | pattern = filetype, 60 | callback = function(ctx) require("scissors.backdrop").new(ctx.buf) end, 61 | }) 62 | return group 63 | end 64 | 65 | -------------------------------------------------------------------------------- 66 | return M 67 | -------------------------------------------------------------------------------- /lua/scissors/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("scissors.utils") 3 | -------------------------------------------------------------------------------- 4 | 5 | local fallbackBorder = "rounded" 6 | 7 | ---@return string 8 | local function getBorder() 9 | local hasWinborder, winborder = pcall(function() return vim.o.winborder end) 10 | if not hasWinborder or winborder == "" or winborder == "none" then return fallbackBorder end 11 | return winborder 12 | end 13 | 14 | -------------------------------------------------------------------------------- 15 | 16 | ---@class Scissors.Config 17 | local defaultConfig = { 18 | snippetDir = vim.fn.stdpath("config") .. "/snippets", 19 | editSnippetPopup = { 20 | height = 0.4, -- relative to the window, between 0-1 21 | width = 0.6, 22 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 23 | keymaps = { 24 | cancel = "q", 25 | saveChanges = "", -- alternatively, can also use `:w` 26 | goBackToSearch = "", 27 | deleteSnippet = "", 28 | duplicateSnippet = "", 29 | openInFile = "", 30 | insertNextPlaceholder = "", -- insert & normal mode 31 | showHelp = "?", 32 | }, 33 | }, 34 | snippetSelection = { 35 | picker = "auto", ---@type "auto"|"telescope"|"snacks"|"vim.ui.select" 36 | 37 | telescope = { 38 | -- By default, the query only searches snippet prefixes. Set this to 39 | -- `true` to also search the body of the snippets. 40 | alsoSearchSnippetBody = false, 41 | 42 | -- accepts the common telescope picker config 43 | opts = { 44 | layout_strategy = "horizontal", 45 | layout_config = { 46 | horizontal = { width = 0.9 }, 47 | preview_width = 0.6, 48 | }, 49 | }, 50 | }, 51 | 52 | -- `snacks` picker configurable via snacks config, 53 | -- see https://github.com/folke/snacks.nvim/blob/main/docs/picker.md 54 | }, 55 | 56 | -- `none` writes as a minified json file using `vim.encode.json`. 57 | -- `yq`/`jq` ensure formatted & sorted json files, which is relevant when 58 | -- you version control your snippets. To use a custom formatter, set to a 59 | -- list of strings, which will then be passed to `vim.system()`. 60 | ---@type "yq"|"jq"|"none"|string[] 61 | jsonFormatter = "none", 62 | 63 | backdrop = { 64 | enabled = true, 65 | blend = 50, -- between 0-100 66 | }, 67 | icons = { 68 | scissors = "󰩫", 69 | }, 70 | } 71 | 72 | -------------------------------------------------------------------------------- 73 | 74 | M.config = defaultConfig -- in case user does not call `setup` 75 | 76 | ---@param userConfig? Scissors.Config 77 | function M.setupPlugin(userConfig) 78 | M.config = vim.tbl_deep_extend("force", defaultConfig, userConfig or {}) 79 | 80 | -- DEPRECATION (2025-04-08) 81 | ---@diagnostic disable: undefined-field 82 | if M.config.telescope then 83 | local msg = 84 | "The nvim-scissors config `telescope` is deprecated. Use `snippetSelection.telescope` instead." 85 | u.notify(msg, "warn") 86 | M.config.snippetSelection.telescope = M.config.telescope 87 | end 88 | ---@diagnostic enable: undefined-field 89 | 90 | -- `preview_width` is only supported by `horizontal` & `cursor` strategies, see #28 91 | local strategy = M.config.snippetSelection.telescope.opts.layout_strategy 92 | if strategy ~= "horizontal" and strategy ~= "cursor" then 93 | M.config.snippetSelection.telescope.opts.layout_config.preview_width = nil 94 | end 95 | 96 | -- normalizing relevant as it expands `~` to the home directory 97 | M.config.snippetDir = vim.fs.normalize(M.config.snippetDir) 98 | 99 | -- border `none` does not work with and title/footer used by this plugin 100 | if M.config.editSnippetPopup.border == "none" or M.config.editSnippetPopup.border == "" then 101 | M.config.editSnippetPopup.border = fallbackBorder 102 | local msg = ('Border type "none" is not supported, falling back to %q'):format(fallbackBorder) 103 | u.notify(msg, "warn") 104 | end 105 | end 106 | 107 | -- filetype used for the popup window of this plugin 108 | M.scissorsFiletype = "scissors-snippet" 109 | 110 | -------------------------------------------------------------------------------- 111 | return M 112 | -------------------------------------------------------------------------------- /lua/scissors/init.lua: -------------------------------------------------------------------------------- 1 | local version = vim.version() 2 | if version.major == 0 and version.minor < 10 then 3 | vim.notify("nvim-scissors requires at least nvim 0.10.", vim.log.levels.WARN) 4 | return 5 | end 6 | -------------------------------------------------------------------------------- 7 | 8 | local M = {} 9 | 10 | ---@param userConfig? Scissors.Config 11 | function M.setup(userConfig) require("scissors.config").setupPlugin(userConfig) end 12 | 13 | function M.addNewSnippet(exCmdArgs) require("scissors.1-prepare-selection").addNewSnippet(exCmdArgs) end 14 | 15 | function M.editSnippet() require("scissors.1-prepare-selection").editSnippet() end 16 | 17 | -------------------------------------------------------------------------------- 18 | return M 19 | -------------------------------------------------------------------------------- /lua/scissors/types.lua: -------------------------------------------------------------------------------- 1 | ---@meta 2 | 3 | ---@class Scissors.SnacksObj 4 | ---@field idx integer 5 | ---@field score integer 6 | ---@field text string 7 | ---@field name string 8 | ---@field snippet Scissors.SnippetObj 9 | ---@field displayName string 10 | 11 | ---@class Scissors.snipFile 12 | ---@field path string 13 | ---@field ft string 14 | ---@field fileIsNew? boolean 15 | 16 | ---DOCS https://code.visualstudio.com/api/language-extensions/snippet-guide 17 | ---@class Scissors.packageJson 18 | ---@field contributes { snippets: Scissors.snippetFileMetadata[] } 19 | 20 | ---@class (exact) Scissors.snippetFileMetadata 21 | ---@field language string|string[] 22 | ---@field path string 23 | 24 | ---@class (exact) Scissors.SnippetObj used by this plugin 25 | ---@field fullPath string (key only set by this plugin) 26 | ---@field filetype string (key only set by this plugin) 27 | ---@field originalKey? string if not set, is a new snippet (key only set by this plugin) 28 | ---@field prefix string[] -- VS Code allows single string, but this plugin converts to array on read 29 | ---@field body string[] -- VS Code allows single string, but this plugin converts to array on read 30 | ---@field description? string 31 | ---@field fileIsNew? boolean -- the file for the snippet is newly created 32 | 33 | ---DOCS https://code.visualstudio.com/docs/editor/userdefinedsnippets#_create-your-own-snippets 34 | ---@alias Scissors.VSCodeSnippetDict table 35 | 36 | ---@class (exact) Scissors.VSCodeSnippet 37 | ---@field prefix string|string[] 38 | ---@field body string|string[] 39 | ---@field description? string 40 | -------------------------------------------------------------------------------- /lua/scissors/utils.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@param msg string 5 | ---@param level? "info"|"warn"|"error"|"debug"|"trace" 6 | ---@param opts? table 7 | function M.notify(msg, level, opts) 8 | if not level then level = "info" end 9 | opts = opts or {} 10 | 11 | opts.title = "scissors" 12 | opts.icon = require("scissors.config").config.icons.scissors 13 | 14 | vim.notify(msg, vim.log.levels[level:upper()], opts) 15 | end 16 | 17 | ---@param snip Scissors.SnippetObj|Scissors.VSCodeSnippet 18 | ---@return string snipName 19 | function M.snipDisplayName(snip) 20 | local snipName = snip.prefix 21 | if type(snipName) == "table" then snipName = table.concat(snipName, " · ") end 22 | if #snipName > 50 then snipName = snipName:sub(1, 50) .. "…" end 23 | return snipName 24 | end 25 | 26 | ---@nodiscard 27 | ---@param path string 28 | ---@return boolean 29 | function M.fileExists(path) 30 | path = vim.fs.normalize(path) 31 | return vim.uv.fs_stat(path) ~= nil 32 | end 33 | 34 | ---DOCS https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax 35 | ---@param bufnr number 36 | function M.tokenHighlight(bufnr) 37 | -- stylua: ignore 38 | local vars = { 39 | -- https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables 40 | "TM_SELECTED_TEXT", "TM_CURRENT_LINE", "TM_CURRENT_WORD", "TM_LINE_INDEX", 41 | "TM_LINE_NUMBER", "TM_FILENAME", "TM_FILENAME_BASE", "TM_DIRECTORY", 42 | "TM_FILEPATH", "CLIPBOARD", "CURRENT_YEAR", "CURRENT_YEAR_SHORT", 43 | "CURRENT_MONTH", "CURRENT_MONTH_NAME", "CURRENT_MONTH_NAME_SHORT", 44 | "CURRENT_DATE", "CURRENT_DAY_NAME", "CURRENT_DAY_NAME_SHORT", 45 | "CURRENT_HOUR", "CURRENT_MINUTE", "CURRENT_SECOND", "CURRENT_SECONDS_UNIX", 46 | "CURRENT_TIMEZONE_OFFSET", "RANDOM", "RANDOM_HEX", "UUID", "LINE_COMMENT", 47 | "BLOCK_COMMENT_START", "BLOCK_COMMENT_END" 48 | } 49 | 50 | vim.api.nvim_buf_call(bufnr, function() 51 | -- escaped dollar 52 | vim.fn.matchadd("@string.escape", [[\\\$]]) 53 | 54 | -- do not highlights dollars signs after a backslash (negative lookbehind) 55 | -- https://neovim.io/doc/user/pattern.html#%2F%5C%40%3C%21 56 | local unescapedDollar = [[\(\\\)\@]] 72 | vim.fn.matchadd(hlgroup, wordBoundariedVars) 73 | end) 74 | end 75 | 76 | -------------------------------------------------------------------------------- 77 | return M 78 | -------------------------------------------------------------------------------- /lua/scissors/vscode-format/convert-object.lua: -------------------------------------------------------------------------------- 1 | -- Functions for converting from/to the VSCode Snippet Format. 2 | -------------------------------------------------------------------------------- 3 | local M = {} 4 | 5 | local rw = require("scissors.vscode-format.read-write") 6 | local u = require("scissors.utils") 7 | local config = require("scissors.config").config 8 | -------------------------------------------------------------------------------- 9 | 10 | ---@param filetype "all"|string 11 | ---@return string[] absPathsOfSnipfileForFt 12 | function M.getSnippetfilePathsForFt(filetype) 13 | local packageJson = rw.readAndParseJson(config.snippetDir .. "/package.json") 14 | ---@cast packageJson Scissors.packageJson 15 | 16 | local snipFilesMetadata = packageJson.contributes.snippets 17 | local absPaths = {} 18 | 19 | for _, metadata in pairs(snipFilesMetadata) do 20 | local lang = metadata.language 21 | if type(lang) == "string" then lang = { lang } end 22 | if vim.tbl_contains(lang, filetype) then 23 | local absPath = config.snippetDir .. "/" .. metadata.path:gsub("^%.?/", "") 24 | table.insert(absPaths, absPath) 25 | end 26 | end 27 | return absPaths 28 | end 29 | 30 | ---@param absPath string of snippet file 31 | ---@param filetype string filetype to assign to all snippets in the file 32 | ---@return Scissors.SnippetObj[] 33 | ---@nodiscard 34 | function M.readVscodeSnippetFile(absPath, filetype) 35 | local vscodeJson = rw.readAndParseJson(absPath) ---@cast vscodeJson Scissors.VSCodeSnippetDict 36 | 37 | local snippetsInFileList = {} ---@type Scissors.SnippetObj[] 38 | 39 | -- convert dictionary to array for `vim.ui.select` 40 | for key, snip in pairs(vscodeJson) do 41 | ---@diagnostic disable-next-line: cast-type-mismatch we are converting it here 42 | ---@cast snip Scissors.SnippetObj 43 | snip.fullPath = absPath 44 | snip.originalKey = key 45 | snip.filetype = filetype 46 | table.insert(snippetsInFileList, snip) 47 | end 48 | 49 | -- VSCode allows body and prefix to be a string. Converts to array on 50 | -- read for consistent handling with nvim-api. 51 | for _, snip in ipairs(snippetsInFileList) do 52 | local rawPrefix = type(snip.prefix) == "string" and { snip.prefix } or snip.prefix 53 | local rawBody = type(snip.body) == "string" and { snip.body } or snip.body 54 | ---@cast rawPrefix string[] -- ensured above 55 | ---@cast rawBody string[] -- ensured above 56 | 57 | -- Strings can contain lines breaks, but nvim-api functions expect each 58 | -- string representing a single line, so we are converting them. 59 | local cleanBody, cleanPrefix = {}, {} 60 | for _, str in ipairs(rawBody) do 61 | local lines = vim.split(str, "\n") 62 | vim.list_extend(cleanBody, lines) 63 | end 64 | for _, str in ipairs(rawPrefix) do 65 | local lines = vim.split(str, "\n") 66 | vim.list_extend(cleanPrefix, lines) 67 | end 68 | 69 | snip.prefix, snip.body = cleanPrefix, cleanBody 70 | end 71 | return snippetsInFileList 72 | end 73 | 74 | ---@param snip Scissors.SnippetObj snippet to update/create 75 | ---@param changedSnippetLines string[] 76 | ---@param prefixCount number determining how many lines in the changes lines belong to the prefix 77 | function M.updateSnippetInVscodeSnippetFile(snip, changedSnippetLines, prefixCount) 78 | local snippetsInFile = rw.readAndParseJson(snip.fullPath) ---@cast snippetsInFile Scissors.VSCodeSnippetDict 79 | 80 | local filepath = snip.fullPath 81 | local prefix = vim.list_slice(changedSnippetLines, 1, prefixCount) 82 | local body = vim.list_slice(changedSnippetLines, prefixCount + 1, #changedSnippetLines) 83 | local isNewSnippet = snip.originalKey == nil 84 | 85 | -- Cleanup 86 | prefix = vim 87 | .iter(prefix) 88 | :map(function(line) return vim.trim(line) end) 89 | :filter(function(line) return line ~= "" end) -- remove deleted prefixes 90 | :totable() 91 | -- trim trailing empty lines from body 92 | while body[#body] == "" do 93 | table.remove(body) 94 | end 95 | -- Auto-escape unescaped literal `$` in the body not used for tabstops or 96 | -- placeholders, since they make the vscode snippets invalid. 97 | -- Note: This does not affect placeholders such as `$foobar`, since those are 98 | -- valid in the VSCode. (Though they result in an empty string if not 99 | -- defined, so in most cases, the user still needs to escape them.) 100 | for i, line in ipairs(body) do 101 | body[i] = line 102 | :gsub("([^\\])%$([^%w{])", "%1\\$%2") -- middle of line 103 | :gsub("^%$([^%w{])", "\\$%1") -- beginning of line 104 | :gsub("([^\\])%$$", "%1\\$") -- end of line 105 | end 106 | 107 | -- convert snipObj to VSCodeSnippet, copy to preserve other properties of the 108 | -- snippet 109 | local vsCodeSnip = vim.deepcopy(snip) 110 | ---@diagnostic disable-next-line: cast-type-mismatch we are converting it here 111 | ---@cast vsCodeSnip Scissors.VSCodeSnippet 112 | vsCodeSnip.prefix = #prefix == 1 and prefix[1] or prefix 113 | vsCodeSnip.body = #body == 1 and body[1] or body 114 | -- delete keys added by this plugin 115 | ---@diagnostic disable: inject-field 116 | vsCodeSnip.fullPath = nil 117 | vsCodeSnip.filetype = nil 118 | vsCodeSnip.originalKey = nil 119 | vsCodeSnip.fileIsNew = nil 120 | ---@diagnostic enable: inject-field 121 | 122 | -- insert item at new key for VSCode format 123 | local key = snip.originalKey 124 | if not key then 125 | key = table.concat(prefix, " + ") 126 | while snippetsInFile[key] ~= nil do -- ensure new key is unique 127 | key = key .. "-1" 128 | end 129 | end 130 | snippetsInFile[key] = vsCodeSnip 131 | 132 | -- write & notify 133 | local success = rw.writeAndFormatSnippetFile(filepath, snippetsInFile, snip.fileIsNew) 134 | if success then 135 | local snipName = u.snipDisplayName(vsCodeSnip) 136 | local action = isNewSnippet and "created" or "updated" 137 | u.notify(("%q %s."):format(snipName, action)) 138 | end 139 | end 140 | 141 | -------------------------------------------------------------------------------- 142 | return M 143 | -------------------------------------------------------------------------------- /lua/scissors/vscode-format/read-write.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local u = require("scissors.utils") 4 | -------------------------------------------------------------------------------- 5 | 6 | ---@param path string 7 | ---@return table 8 | function M.readAndParseJson(path) 9 | local name = vim.fs.basename(path) 10 | local file, _ = io.open(path, "r") 11 | assert(file, name .. " could not be read") 12 | local content = file:read("*a") 13 | file:close() 14 | local ok, json = pcall(vim.json.decode, content) 15 | if not (ok and json) then 16 | u.notify("Could not parse " .. name, "warn") 17 | return {} 18 | end 19 | return json 20 | end 21 | 22 | ---@param filepath string 23 | ---@param text string 24 | ---@return boolean success 25 | function M.writeFile(filepath, text) 26 | local file, _ = io.open(filepath, "w") 27 | assert(file, "Could not write to " .. filepath) 28 | file:write(text) 29 | local success = file:close() or false 30 | if not success then u.notify("Could not write to " .. filepath, "error") end 31 | return success 32 | end 33 | 34 | ---@param filepath string 35 | ---@param jsonObj Scissors.VSCodeSnippetDict|Scissors.packageJson 36 | ---@param fileIsNew? boolean 37 | ---@return boolean success 38 | function M.writeAndFormatSnippetFile(filepath, jsonObj, fileIsNew) 39 | local jsonFormatter = require("scissors.config").config.jsonFormatter 40 | 41 | local ok, jsonStr = pcall(vim.json.encode, jsonObj) 42 | assert(ok and jsonStr, "Could not encode JSON.") 43 | 44 | -- FORMAT 45 | -- INFO sorting via `yq` or `jq` is necessary, since `vim.json.encode` 46 | -- does not ensure a stable order of keys in the written JSON. 47 | if jsonFormatter ~= "none" then 48 | local cmds = { 49 | -- DOCS https://mikefarah.gitbook.io/yq/operators/sort-keys 50 | yq = { 51 | "yq", 52 | "--prettyPrint", 53 | "--output-format=json", 54 | "--input-format=json", -- different parser, more stable https://github.com/mikefarah/yq/issues/1265#issuecomment-1200784274 55 | "--no-colors", -- safety net for some shells 56 | "sort_keys(..)", 57 | }, 58 | -- DOCS https://jqlang.github.io/jq/manual/#invoking-jq 59 | jq = { "jq", "--sort-keys", "--monochrome-output" }, 60 | } 61 | local shellCmd = type(jsonFormatter) == "table" and jsonFormatter or cmds[jsonFormatter] 62 | local result = vim.system(shellCmd, { stdin = jsonStr }):wait() 63 | 64 | if result.code ~= 0 then 65 | u.notify("JSON formatting failed: " .. result.stderr, "error") 66 | return false 67 | end 68 | jsonStr = result.stdout ---@cast jsonStr string 69 | end 70 | 71 | -- WRITE & RELOAD 72 | local writeSuccess = M.writeFile(filepath, jsonStr) 73 | if not writeSuccess then return false end 74 | 75 | if not vim.endswith(filepath, "package.json") then 76 | require("scissors.4-hot-reload").reloadSnippetFile(filepath, fileIsNew) 77 | end 78 | 79 | return true 80 | end 81 | 82 | ---@param snip Scissors.SnippetObj 83 | function M.deleteSnippet(snip) 84 | local key = assert(snip.originalKey) 85 | local snippetsInFile = M.readAndParseJson(snip.fullPath) 86 | ---@cast snippetsInFile Scissors.VSCodeSnippetDict 87 | snippetsInFile[key] = nil -- = delete 88 | 89 | local success = M.writeAndFormatSnippetFile(snip.fullPath, snippetsInFile) 90 | if success then 91 | local msg = ("%q deleted."):format(u.snipDisplayName(snip)) 92 | u.notify(msg) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- 96 | return M 97 | -------------------------------------------------------------------------------- /lua/scissors/vscode-format/validate-bootstrap.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local rw = require("scissors.vscode-format.read-write") 4 | local u = require("scissors.utils") 5 | -------------------------------------------------------------------------------- 6 | 7 | ---@param snipDir string 8 | ---@return boolean 9 | ---@nodiscard 10 | function M.validate(snipDir) 11 | -- empty filetype 12 | if vim.bo.filetype == "" then 13 | u.notify("`nvim-scissors` requires the current buffer to have a filetype.", "warn") 14 | return false 15 | end 16 | 17 | local snipDirType = (vim.uv.fs_stat(snipDir) or {}).type 18 | local packageJsonExists = u.fileExists(snipDir .. "/package.json") 19 | local isFriendlySnippetsDir = snipDir:find("/friendly%-snippets/") 20 | and not vim.startswith(snipDir, vim.fn.stdpath("config")) 21 | 22 | -- snippetDir invalid 23 | if snipDirType ~= "directory" then 24 | u.notify(("%q is not a directory."):format(snipDir), "error") 25 | return false 26 | 27 | -- package.json invalid 28 | elseif packageJsonExists then 29 | local packageJson = rw.readAndParseJson(snipDir .. "/package.json") 30 | if 31 | vim.tbl_isempty(packageJson) 32 | or not (packageJson.contributes and packageJson.contributes.snippets) 33 | then 34 | u.notify( 35 | "The `package.json` in your `snippetDir` is invalid.\n" 36 | .. "Please make sure it follows the required specification for VSCode snippets.", 37 | "error" 38 | ) 39 | return false 40 | end 41 | 42 | -- using friendly-snippets 43 | elseif isFriendlySnippetsDir then 44 | u.notify( 45 | "Snippets from `friendly-snippets` should be edited directly, since any changes would be overwritten as soon as the repo is updated.\n" 46 | .. "Copy the snippet files you want from the repo into your snippet directory and edit them there.", 47 | "error" 48 | ) 49 | return false 50 | end 51 | 52 | return true 53 | end 54 | 55 | -- bootstrap if snippetDir and/or `package.json` do not exist 56 | ---@param snipDir string 57 | function M.bootstrapSnipDirIfNeeded(snipDir) 58 | local snipDirExists = u.fileExists(snipDir) 59 | local packageJsonExists = u.fileExists(snipDir .. "/package.json") 60 | local msg = "" 61 | 62 | if not snipDirExists then 63 | local success = vim.fn.mkdir(snipDir, "p") 64 | assert(success == 1, snipDir .. " does not exist and could not be created.") 65 | msg = msg .. "Snippet directory does not exist. Creating one.\n" 66 | end 67 | if not packageJsonExists then 68 | local packageJsonStr = [[ 69 | { 70 | "contributes": { 71 | "snippets": [] 72 | }, 73 | "description": "This package.json has been generated by nvim-scissors.", 74 | "name": "my-snippets" 75 | } 76 | ]] 77 | rw.writeFile(snipDir .. "/package.json", packageJsonStr) 78 | msg = msg .. "`package.json` does not exist. Bootstrapping one.\n" 79 | end 80 | 81 | if msg ~= "" then u.notify(vim.trim(msg)) end 82 | end 83 | 84 | ---Write a new snippet file for the given filetype, update package.json, and 85 | ---returns the snipFile. 86 | ---@param ft string 87 | ---@param contents? string -- defaults to `{}` 88 | ---@return Scissors.snipFile -- the newly created snippet file 89 | function M.bootstrapSnippetFile(ft, contents) 90 | local snipDir = require("scissors.config").config.snippetDir 91 | local newSnipName = ft .. ".json" 92 | 93 | -- create empty snippet file 94 | local newSnipFilepath 95 | while true do 96 | newSnipFilepath = snipDir .. "/" .. newSnipName 97 | if not u.fileExists(newSnipFilepath) then break end 98 | newSnipName = newSnipName .. "-1" 99 | end 100 | rw.writeFile(newSnipFilepath, contents or "{}") 101 | 102 | -- update package.json 103 | local packageJson = rw.readAndParseJson(snipDir .. "/package.json") ---@type Scissors.packageJson 104 | table.insert(packageJson.contributes.snippets, { 105 | language = { ft }, 106 | path = "./" .. newSnipName, 107 | }) 108 | rw.writeAndFormatSnippetFile(snipDir .. "/package.json", packageJson) 109 | 110 | -- return snipFile to directly add to it 111 | return { ft = ft, path = newSnipFilepath, fileIsNew = true } 112 | end 113 | 114 | -------------------------------------------------------------------------------- 115 | return M 116 | -------------------------------------------------------------------------------- /plugin/ex-commands.lua: -------------------------------------------------------------------------------- 1 | vim.api.nvim_create_user_command( 2 | "ScissorsAddNewSnippet", 3 | function(args) require("scissors.1-prepare-selection").addNewSnippet(args) end, 4 | { desc = "Add new snippet.", range = true } 5 | ) 6 | 7 | vim.api.nvim_create_user_command( 8 | "ScissorsEditSnippet", 9 | function() require("scissors.1-prepare-selection").editSnippet() end, 10 | { desc = "Edit existing snippet." } 11 | ) 12 | --------------------------------------------------------------------------------