├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------