├── .github └── workflows │ └── ci.yml ├── LICENSE ├── Makefile ├── README.md ├── doc └── SnippSnapp.txt ├── plugin └── snippet.vim ├── runtest.vim └── test └── test_expand.vim /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Install Vim 11 | run: sudo add-apt-repository -yu ppa:jonathonf/vim && sudo apt-get install -qq vim 12 | - name: Run tests 13 | run: make check 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Axel Forsman 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: check 2 | 3 | REDIR_TEST_TO_NULL = >/dev/null 2>&1 4 | 5 | TESTS = $(wildcard test/test_*.vim) 6 | 7 | $(TESTS): 8 | vim --clean --not-a-term -u runtest.vim "$@" $(REDIR_TEST_TO_NULL) || { cat testlog; false; } 9 | 10 | check: $(TESTS) 11 | 12 | .PHONY: all check $(TESTS) 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vim-snipp-snapp-snut-and-the-legend-continues 2 | ![](https://github.com/axelf4/vim-snipp-snapp-snut-and-the-legend-continues/workflows/CI/badge.svg) 3 | 4 | Experimental Vim plugin utilizing text properties to track placeholder text. 5 | 6 | Aims to be a modern [UltiSnips] alternative without the Python dependency, nor the complexity from trying to track buffer changes, which is offloaded to Vim via textprops. Current deficiencies in textprops mean that placeholders cannot span multiple lines, but this will be fixed in the future. 7 | 8 | Checkout the [documentation][doc]. 9 | 10 | [doc]: doc/SnippSnapp.txt 11 | [UltiSnips]: https://github.com/SirVer/ultisnips 12 | -------------------------------------------------------------------------------- /doc/SnippSnapp.txt: -------------------------------------------------------------------------------- 1 | *SnippSnapp.txt* Plugin for snippets 2 | 3 | ============================================================================== 4 | 1. Introduction *SnippSnapp-introduction* 5 | 6 | SnippSnapp is a snippet manager for Vim. Snippets are templates that can be 7 | inserted to ease writing repetitious text. 8 | 9 | This plugin requires Vim version 8.2 or later and the |+textprop| feature 10 | along with a set of complementary patches. 11 | 12 | ============================================================================== 13 | 2. Authoring snippets *SnippSnapp-authoring-snippets* 14 | 15 | Each entry in 'runtimepath' is looked inside for a SnippSnapp directory 16 | containing snippet definition files. If "ft" is the 'filetype' of the current 17 | buffer, then snippets in files matching "ft.snippets" will be available. The 18 | "all" filetype is special; those snippets will apply to every buffer. 19 | 20 | In a snippets file, empty lines or those starting with a # character are 21 | ignored. 22 | 23 | Each snippet definition takes the following form: > 24 | 25 | snippet /trigger/ [ "Description" ] 26 | expanded text 27 | more expanded text 28 | endsnippet 29 | 30 | < 31 | While the trigger is required, the description is optional. The "/" trigger 32 | delimiters are not part of the trigger, and must not appear in it, though they 33 | can be any arbitrary non-whitespace character, that is, one could use quotes 34 | instead. The last newline character before the isolated "endsnippet" line is 35 | not considered part of the snippet. 36 | 37 | Balanced brace pairs do not have to be escaped. 38 | 39 | The body of a snippet can use some special constructs to control snippet 40 | expansion: 41 | 42 | 43 | PLACEHOLDERS *SnippSnapp-placeholder* 44 | 45 | The syntax for a placeholder is "${number [ : default content ]}", for example 46 | "${1:foo}". The placeholder text will be selected such that it can be easily 47 | modified. They will be visited in increasing order, ending with "${0}". If 48 | there is no "${0}" defined, it will implicitly be appended at the end of the 49 | snippet. Note: Placeholder numbers must be unique. (To have subsequent usages 50 | mirror the first one: Use mirrors instead!) 51 | 52 | Placeholders can be nested, like "${1:another ${2:placeholder}}", but there 53 | are some caveats. 54 | 55 | 56 | MIRRORS *SnippSnapp-interpolation* *SnippSnapp-mirror* 57 | 58 | Mirrors can be used for two purposes: (1) To reflect the content of a 59 | placeholder; or (2) To embed the result of evaluating a Vim script expression; 60 | or both. Their syntax is "`{Vim script expression}`". Inside the expression 61 | any "${number}" will be replaced with an expression evaluating to the content 62 | of the corresponding placeholder. 63 | 64 | This is useful for things such as TeX environments. Here is an example 65 | snippet: > 66 | 67 | snippet "env" 68 | \begin{${1:center}} 69 | ${0} 70 | \begin{`$1`} 71 | endsnippet 72 | 73 | < 74 | The graph induced by the DEPENDS ON equivalence relation on the set of 75 | placeholders and mirrors must be acyclic. This is statically checked. 76 | 77 | During evaluation of the mirror the internal variable "g:m" contains the 78 | trigger match, in a format alike what |matchlist()| returns. 79 | 80 | *SnippSnapp-transformation* 81 | Transformations, present in e.g. TextMate, do not have their own special 82 | syntax. Instead mirrors can serve the purpose, using |substitute()|. For 83 | example: > 84 | `${placeholder_no}->substitute({pattern}, {replacement}, {options})` 85 | 86 | 87 | vim:tw=78:ts=8:noet:ft=help:norl: 88 | -------------------------------------------------------------------------------- /plugin/snippet.vim: -------------------------------------------------------------------------------- 1 | " Vim plugin for snippets 2 | if !(has('textprop') && has("patch-8.2.324") && has("patch-8.2.357") 3 | \ && has("patch-8.2.372")) 4 | throw 'Incompatible Vim version!' 5 | endif 6 | 7 | if exists('g:loaded_snipp_snapp_snut') | finish | endif 8 | let g:loaded_snipp_snapp_snut = 1 9 | 10 | call prop_type_add('placeholder', #{start_incl: 1, end_incl: 1}) 11 | call prop_type_add('mirror', #{start_incl: 1, end_incl: 1}) 12 | 13 | let s:next_prop_id = 0 14 | let g:placeholder_values = {} " TODO Temporarily used for mirror evaluation 15 | let s:snippets_by_ft = {} 16 | 17 | " Replace the specified range with the given text. 18 | " 19 | " Start is inclusive, while end is exclusive. 20 | " {text} is the replacement consisting of a List of lines. 21 | " 22 | " Tries to respect text properties. 23 | function s:Edit(lnum, col, end_lnum, end_col, text) abort 24 | if a:end_lnum < a:lnum || (a:lnum == a:end_lnum && a:end_col < a:col) 25 | throw 'Start is past end?' 26 | endif 27 | let [bufnum, lnum, col, _, curswant] = getcurpos() 28 | execute printf('keeppatterns %dsubstitute/\%%%dc\_.*\%%%dl\%%%dc/%s', 29 | \ a:lnum, a:col, a:end_lnum, a:end_col, a:text->join("\")->escape('/\')) 30 | 31 | " Update cursor position 32 | if (a:lnum < lnum || a:lnum == lnum && a:col <= col) 33 | \ && (lnum < a:lnum || lnum == a:lnum && col < a:col) " Cursor was inside edit 34 | let [lnum, col] = [a:end_lnum, a:end_col] " Move cursor to end of edit 35 | elseif a:end_lnum < lnum || a:end_lnum == lnum && a:end_col <= col " Cursor was after edit 36 | if a:end_lnum == lnum 37 | let col += (a:text->empty() ? 0 : a:text[-1]->len()) 38 | \ - (a:end_col - (a:lnum == a:end_lnum ? a:col : 1)) 39 | endif 40 | let lnum += a:text->len() - (a:end_lnum - a:lnum) - 1 41 | endif 42 | call setpos('.', [bufnum, lnum, col, 0, col]) 43 | endfunction 44 | 45 | " Start Select mode with the specified area. 46 | " 47 | " The implementation is terrible to support CTRL-R =. 48 | function s:Select(lnum, col, end_lnum, end_col) abort 49 | " TODO Handle all cases of &selection 50 | let save_virtualedit = &virtualedit 51 | let zero_len = a:lnum == a:end_lnum && a:col == a:end_col 52 | call feedkeys("\:set virtualedit=onemore | call cursor(" .. a:lnum .. ',' .. a:col .. ")\" 53 | \ .. (zero_len ? "i\:set virtualedit=" .. save_virtualedit .. "\" 54 | \ : "v:\call cursor(" .. a:end_lnum .. ',' .. a:end_col .. ")\\:set virtualedit=" .. save_virtualedit .. "\v`<\"), 55 | \ 'ni') 56 | endfunction 57 | 58 | " Search for property with {id} starting after/before {ref} on {lnum}. 59 | " 60 | " If {direction} > 0: Search forward; if < 0: Backward. 61 | function s:PropFindRelative(ref, id, lnum, direction) abort 62 | " FIXME Cannot provide a type='placeholder/mirror' here because it is an OR... 63 | " Careful: ref might span many lines with match above lnum 64 | let start = a:direction < 0 || a:ref.start ? a:ref 65 | \ : prop_find(#{id: a:ref.id, lnum: a:lnum, col: a:ref.col}, 'b') 66 | return prop_find(#{id: a:id, lnum: start->get('lnum', a:lnum), col: start.col}, 67 | \ a:direction < 0 ? 'b' : 'f') 68 | endfunction 69 | 70 | function s:SelectProp(prop) abort 71 | " TODO Add support for multiline props 72 | call s:Select(a:prop.lnum, a:prop.col, a:prop.lnum, a:prop.col + a:prop.length) 73 | endfunction 74 | 75 | " Returns the text content of the textprop {prop}. 76 | function s:PropContent(prop, lnum) abort 77 | " TODO Handle multiline props 78 | return getline(a:lnum)->strpart(a:prop.col - 1, a:prop.length) 79 | endfunction 80 | 81 | function s:Flatten(list) abort 82 | let result = [] 83 | for item in a:list | eval result->extend(item) | endfor 84 | return result 85 | endfunction 86 | 87 | function s:FlatMap(list, F) abort 88 | let result = [] 89 | for item in a:list | eval result->extend(a:F(item)) | endfor 90 | return result 91 | endfunction 92 | 93 | " Tests if every element of {list} matches the predicate {F}. 94 | function s:All(list, F) abort 95 | for item in a:list | if !a:F(item) | return 0 | endif | endfor 96 | return 1 97 | endfunction 98 | 99 | " Return the snippets whose trigger matches at the cursor and their matches. 100 | function s:PossibleSnippets() abort 101 | let snippets = [] 102 | let [line, col] = [getline('.'), col('.')] 103 | for snippet in s:SnippetFiletypes()->s:FlatMap({ft -> s:snippets_by_ft->get(ft, [])}) 104 | let match = line->matchlist(printf('\%%(%s\)\%%%dc', snippet.trigger, col)) 105 | if !empty(match) 106 | eval snippets->add([snippet, match]) 107 | endif 108 | endfor 109 | return snippets 110 | endfunction 111 | 112 | " Try to expand a snippet or jump to the next tab stop. 113 | " 114 | " Returns false if failed. 115 | function s:ExpandOrJump() abort 116 | let possible = s:PossibleSnippets() 117 | return !empty(possible) ? Expand(possible[0][0], possible[0][1]) : s:Jump() 118 | endfunction 119 | 120 | " Expand {snippet} at the cursor location. 121 | function Expand(snippet, match) abort 122 | let [_, lnum, col; rest] = getcurpos() 123 | let length = a:match[0]->len() 124 | let col -= length 125 | 126 | let cached_placeholders = {} 127 | let instance = #{ 128 | \ snippet: a:snippet, match: a:match, 129 | \ cached_placeholders: cached_placeholders, dirty_mirrors: {}, 130 | \ } 131 | " Suboptimal iteration for a fixed-point 132 | let finished = 0 133 | while !finished 134 | let finished = 1 135 | let builder = #{col: col, lnum: lnum, text: [""]} 136 | let indent = getline(lnum)->matchstr('^\s*') 137 | function builder.append(string) abort 138 | let self.text[-1] ..= a:string 139 | let self.col += a:string->len() 140 | endfunction 141 | function builder.new_line() abort closure 142 | eval self.text->add(indent) 143 | let self.lnum += 1 144 | let self.col = 1 + indent->len() 145 | endfunction 146 | " Returns the text from the specified start position to the end. 147 | function builder.get_text(lnum, col) abort 148 | let lines = self.text[-1 - (self.lnum - a:lnum):-1] 149 | let lines[0] = lines[0]->strpart(a:col - 1) 150 | return lines 151 | endfunction 152 | 153 | let [placeholders, mirrors] = [[], []] 154 | function! s:HandleContent(content) abort closure 155 | for item in a:content 156 | if item.type ==# 'text' 157 | call builder.append(item.content) 158 | if item->get('is_eol', 0) | call builder.new_line() | endif 159 | elseif item.type ==# 'placeholder' 160 | let [start_lnum, start_col] = [builder.lnum, builder.col] 161 | call s:HandleContent(item.initial) 162 | if !empty(a:snippet.placeholder_dependants->get(item.number, [])) 163 | let cached_placeholders[item.number] = builder.get_text(start_lnum, start_col) 164 | \ ->join("\n") 165 | endif 166 | eval placeholders->add(#{ 167 | \ lnum: start_lnum, col: start_col, 168 | \ end_lnum: builder.lnum, end_col: builder.col, 169 | \ number: item.number, 170 | \ }) 171 | elseif item.type ==# 'mirror' 172 | let mirror = a:snippet.mirrors[item.id] 173 | if !(mirror.dependencies->s:All({v -> cached_placeholders->has_key(v)})) 174 | let finished = 0 175 | endif 176 | let text = finished ? mirror->s:EvalMirror(instance) : '' 177 | let [start_lnum, start_col] = [builder.lnum, builder.col] 178 | call builder.append(text) 179 | if !empty(mirror.dependencies) 180 | eval mirrors->add(#{id: item.id, lnum: start_lnum, col: start_col, 181 | \ end_lnum: builder.lnum, end_col: builder.col}) 182 | endif 183 | else | throw 'Bad type' | endif 184 | endfor 185 | endfunction 186 | call s:HandleContent(a:snippet.content) 187 | endwhile 188 | 189 | call s:Edit(lnum, col, lnum, col + length, builder.text) 190 | 191 | let instance.first_placeholder_id = s:next_prop_id 192 | let instance.first_mirror_id = instance.first_placeholder_id + placeholders->len() 193 | let s:next_prop_id += placeholders->len() + a:snippet.mirrors->len() 194 | 195 | if placeholders->len() == 1 " Jump to last placeholder 196 | let prop_zero = placeholders[0] 197 | call s:Select(prop_zero.lnum, prop_zero.col, prop_zero.end_lnum, prop_zero.end_col) 198 | else 199 | let first_placeholder = placeholders->len() > 1 ? 1 : 0 200 | for placeholder in placeholders 201 | let placeholder_id = instance.first_placeholder_id + placeholder.number 202 | call prop_add(placeholder.lnum, placeholder.col, #{ 203 | \ end_lnum: placeholder.end_lnum, end_col: placeholder.end_col, 204 | \ type: 'placeholder', id: placeholder_id, 205 | \ }) 206 | if placeholder.number == first_placeholder 207 | call s:Select(placeholder.lnum, placeholder.col, 208 | \ placeholder.end_lnum, placeholder.end_col) 209 | endif 210 | endfor 211 | 212 | for mirror in mirrors 213 | call prop_add(mirror.lnum, mirror.col, #{ 214 | \ end_lnum: mirror.end_lnum, end_col: mirror.end_col, 215 | \ type: 'mirror', id: instance.first_mirror_id + mirror.id, 216 | \ }) 217 | endfor 218 | 219 | eval b:snippet_stack->add(instance) 220 | endif 221 | return 1 222 | endfunction 223 | 224 | function s:PopActiveSnippet() abort 225 | if b:snippet_stack->empty() | throw 'Popping empty stack?' | endif 226 | let instance = b:snippet_stack->remove(-1) 227 | for placeholder_id in range(instance.first_placeholder_id, 228 | \ instance.first_placeholder_id + instance.snippet.placeholders->len() - 1) 229 | call prop_remove(#{id: placeholder_id, type: 'placeholder', both: 1, all: 1}) 230 | endfor 231 | for mirror_id in range(instance.first_mirror_id, 232 | \ instance.first_mirror_id + instance.snippet.mirrors->len() - 1) 233 | call prop_remove(#{id: mirror_id, type: 'mirror', both: 1, all: 1}) 234 | endfor 235 | endfunction 236 | 237 | " Return whether placeholder {id} belongs to snippet {instance}. 238 | function s:HasPlaceholder(instance, id) abort 239 | return a:instance.first_placeholder_id <= a:id 240 | \ && a:id < a:instance.first_placeholder_id + a:instance.snippet.placeholders->len() 241 | endfunction 242 | 243 | " Return the index of the snippet instance containing the placeholder with {id} or -1. 244 | function s:InstanceIdOfPlaceholder(id) abort 245 | for i in range(b:snippet_stack->len() - 1, 0, -1) 246 | if b:snippet_stack[i]->s:HasPlaceholder(a:id) | return i | endif 247 | endfor 248 | return -1 249 | endfunction 250 | 251 | let s:NextPlaceholderId = {id, instance, direction -> direction ==# 'f' 252 | \ ? (id >= instance.snippet.placeholders->len() - 1 ? 0 : id + 1) : id - 1} 253 | 254 | " Jump to the next/previous placeholder relative to the one at the cursor. 255 | " 256 | " Optional argument [direction] can be "f" for forward, or "b" for backward. 257 | " Returns whether successful. 258 | function s:Jump(...) abort 259 | let direction = a:000->get(0, 'f') 260 | let [lnum, col] = [line('.'), col('.')] 261 | " Get all placeholders containing the cursor sorted after specificity 262 | let current_props = prop_list(lnum)->filter({_, v -> v.type ==# 'placeholder' 263 | \ && v.col <= col && col <= v.col + v.length}) 264 | \ ->sort({a, b -> b.id - a.id}) 265 | for placeholder_prop in current_props 266 | while 1 267 | if b:snippet_stack->empty() " Undo etc can cause stray placeholder props 268 | call prop_remove(#{type: 'placeholder', all: 1}) 269 | return 270 | endif 271 | if b:snippet_stack[-1]->s:HasPlaceholder(placeholder_prop.id) | break | endif 272 | call s:PopActiveSnippet() 273 | endwhile 274 | let instance = b:snippet_stack[-1] 275 | let number = placeholder_prop.id - instance.first_placeholder_id 276 | 277 | while number > (direction ==# 'f' ? 0 : 1) 278 | let next = s:NextPlaceholderId(number, instance, direction) 279 | let direction = instance.snippet.placeholders[next].order 280 | \ - instance.snippet.placeholders[number].order 281 | let prop = placeholder_prop->s:PropFindRelative(instance.first_placeholder_id + next, 282 | \ lnum, direction) 283 | " If jumping to last placeholder: Snippet is done! 284 | if next == 0 | call s:PopActiveSnippet() | endif 285 | 286 | if !empty(prop) " Found property to jump to! 287 | call s:SelectProp(prop) " Leave user editing the next tab stop 288 | return 1 289 | endif 290 | let number = next 291 | endwhile 292 | endfor 293 | endfunction 294 | 295 | function! s:AssertNoCycle(nodes) abort 296 | function! s:DetectCycles(nodes, node) abort 297 | if a:node.color == 1 | throw 'Detected cycle!' | endif 298 | let a:node.color = 1 299 | for child_key in a:node.children 300 | call s:DetectCycles(a:nodes, a:nodes[child_key]) 301 | endfor 302 | let a:node.color = 2 303 | endfunction 304 | 305 | for node in a:nodes->values() 306 | if node.color != 0 | continue | endif 307 | call s:DetectCycles(a:nodes, node) 308 | endfor 309 | endfunction 310 | 311 | " Parse the snippet body the List {text} of lines. 312 | " 313 | " Uses a recursive descent parser. 314 | function ParseSnippet(text) abort 315 | let lexer = #{text: a:text, lnum: 0, col: 0, queue: []} 316 | function lexer.has_eof() abort 317 | return self.lnum >= self.text->len() 318 | endfunction 319 | function lexer.next_symbol() abort 320 | while self.queue->empty() && !self.has_eof() 321 | let line = self.text[self.lnum] 322 | " TODO Allow escape sequences 323 | let [match, start, end] = line->matchstrpos('${\d\+:\?\|{\|}\|`[^`]\+`\|$', self.col) 324 | let before = line->strpart(self.col, start - self.col) 325 | if !empty(before) | eval self.queue->add(#{type: 'text', content: before}) | endif 326 | 327 | if !empty(match) 328 | if match[0] == '{' || match[0] == '}' 329 | eval self.queue->add(#{type: match}) 330 | elseif match[0] == '$' 331 | eval self.queue->add(#{ 332 | \ type: 'placeholder', 333 | \ number: +matchstr(match, '\d\+'), 334 | \ has_inital: match =~# ':$', 335 | \ }) 336 | elseif match[0] == '`' 337 | eval self.queue->add(#{type: 'mirror', value: match[1:-2]}) 338 | else 339 | throw 'Strange match?: "' .. match .. '"' 340 | endif 341 | endif 342 | 343 | let self.col = end 344 | if end >= line->len() 345 | let self.lnum += 1 346 | let self.col = 0 347 | eval self.queue->add(#{type: 'text', content: '', is_eol: 1}) 348 | endif 349 | endwhile 350 | endfunction 351 | 352 | function! lexer.accept(type) abort 353 | if self.queue->empty() | call self.next_symbol() | endif 354 | if self.queue->empty() | return 0 | endif 355 | if self.queue[0].type ==# a:type 356 | return self.queue->remove(0) 357 | endif 358 | return 0 359 | endfunction 360 | 361 | function! lexer.expect(type) abort 362 | let token = self.accept(a:type) 363 | if token is 0 | throw 'Expected type: ' .. a:type .. ', found other' | endif 364 | endfunction 365 | 366 | " Parse the content of a snippet. 367 | function! s:ParseContent() abort closure 368 | let result = [] 369 | while 1 370 | let item = lexer.accept('text') 371 | if item is 0 | let item = s:ParsePlaceholder() | endif 372 | if item is 0 | let item = s:ParseBracketPair() | endif 373 | if item is 0 | let item = s:ParseMirror() | endif 374 | if item is 0 | break | endif 375 | if v:t_list == item->type() 376 | eval result->extend(item) 377 | else 378 | eval result->add(item) 379 | endif 380 | endwhile 381 | return result 382 | endfunction 383 | 384 | function! s:ParseBracketPair() abort closure 385 | let token = lexer.accept('{') 386 | if token is 0 | return 0 | endif 387 | let result = s:ParseContent() 388 | eval result->insert(#{type: 'text', content: '{'}, 0) 389 | eval result->add(#{type: 'text', content: '}'}) 390 | call lexer.expect('}') 391 | return result 392 | endfunction 393 | 394 | let placeholders = {} 395 | let placeholder_dependants = {} 396 | let mirrors = [] 397 | let placeholder_nodes = {} " Nodes in graph induced by DEPENDS ON relation 398 | let current_placeholder_node = v:null 399 | 400 | function! s:ParsePlaceholder() abort closure 401 | let token = lexer.accept('placeholder') 402 | if token is 0 | return 0 | endif 403 | if placeholders->has_key(token.number) | throw 'Duplicate placeholder' | endif 404 | 405 | if current_placeholder_node isnot v:null 406 | eval current_placeholder_node.children->add(token.number) 407 | endif 408 | let [prev_placeholder_node, current_placeholder_node] = [current_placeholder_node, #{color: 0, children: []}] 409 | let placeholder_nodes[token.number] = current_placeholder_node 410 | 411 | let placeholders[token.number] = #{order: placeholders->len() + mirrors->len()} 412 | let placeholder = #{type: 'placeholder', number: token.number, 413 | \ initial: token.has_inital ? s:ParseContent() : [],} 414 | call lexer.expect('}') 415 | 416 | let current_placeholder_node = prev_placeholder_node 417 | return placeholder 418 | endfunction 419 | 420 | function! s:ParseMirror() abort closure 421 | let token = lexer.accept('mirror') 422 | if token is 0 | return 0 | endif 423 | let mirror_id = mirrors->len() 424 | let dependencies = [] 425 | function! s:MirrorReplace(m) abort closure 426 | let [match, placeholder_number; rest] = a:m 427 | let placeholder_number = +placeholder_number 428 | 429 | if current_placeholder_node isnot v:null 430 | eval current_placeholder_node.children->add(placeholder_number) 431 | endif 432 | 433 | if !(placeholder_dependants->has_key(placeholder_number)) 434 | let placeholder_dependants[placeholder_number] = [] 435 | endif 436 | eval placeholder_dependants[placeholder_number]->add(mirror_id) 437 | eval dependencies->add(placeholder_number) 438 | 439 | return 'g:placeholder_values[' .. placeholder_number .. ']' 440 | endfunction 441 | let value = token.value->substitute('$\(\d\+\)', funcref('s:MirrorReplace'), 'g') 442 | eval mirrors->add(#{value: value, dependencies: dependencies, 443 | \ order: placeholders->len() + mirrors->len()}) 444 | return #{type: 'mirror', id: mirror_id, value: value} 445 | endfunction 446 | 447 | let content = s:ParseContent() 448 | " Remove last EOL 449 | if !empty(content) && content[-1]->get('is_eol', 0) | eval content->remove(-1) | endif 450 | " Add tab stop #0 after snippet if needed 451 | if !(placeholders->has_key('0')) 452 | eval content->add(#{type: 'placeholder', number: 0, initial: []}) 453 | let placeholders[0] = #{order: placeholders->len()} 454 | let placeholder_nodes[0] = #{color: 0, children: []} 455 | endif 456 | 457 | call s:AssertNoCycle(placeholder_nodes) 458 | 459 | return #{content: content, placeholders: placeholders, mirrors: mirrors, 460 | \ placeholder_dependants: placeholder_dependants, 461 | \ } 462 | endfunction 463 | 464 | " Parse snippet definitions from the List {text} of lines. 465 | function s:ParseSnippets(text) abort 466 | let snippets = [] 467 | let lnum = 0 468 | 469 | function! s:UntilEnd() abort closure 470 | let start = lnum 471 | while a:text[lnum] !~# '^endsnippet' 472 | let lnum += 1 473 | endwhile 474 | let lnum += 1 475 | return a:text[start:lnum - 2] 476 | endfunction 477 | 478 | while lnum < a:text->len() 479 | let line = a:text[lnum] 480 | let lnum += 1 481 | 482 | " Ignore empty lines and comment 483 | if line =~# '^\s*$\|^#' | continue | endif 484 | 485 | let res = line->matchlist('^snippet\s\+\(.\)\(\%(\1\@!.\)*\)\@>\1\%(\s\+"\([^"]*\)"\)\?') 486 | if res->empty() | throw 'Bad line ' .. line | endif 487 | let [match, _, trigger, desc; rest] = res 488 | let snippet = ParseSnippet(s:UntilEnd()) 489 | let snippet.trigger = trigger 490 | let snippet.description = desc 491 | eval snippets->add(snippet) 492 | endwhile 493 | 494 | return snippets 495 | endfunction 496 | 497 | let s:listener_disabled = 0 498 | function s:Listener(bufnr, start, end, added, changes) abort 499 | if b:snippet_stack->empty() | return | endif " Quit early if there are no active snippets 500 | 501 | let max_instance_nr = -1 " Largest snippet instance index seen 502 | for change in a:changes 503 | " Skip deletions since no efficient way to know if snippet was deleted 504 | if change.added < 0 | continue | endif 505 | 506 | for lnum in range(change.lnum, change.end + change.added - 1) 507 | for prop in prop_list(lnum) 508 | if prop.type !=# 'placeholder' || prop.col + prop.length < change.col | continue | endif 509 | let instance_id = prop.id->s:InstanceIdOfPlaceholder() 510 | if instance_id == -1 511 | " Undo can re-add old props, if so: Remove them 512 | call prop_remove(#{id: prop.id, type: 'placeholder', both: 1}, lnum) 513 | continue 514 | endif 515 | if instance_id > max_instance_nr | let max_instance_nr = instance_id | endif 516 | let instance = b:snippet_stack[instance_id] 517 | 518 | let placeholder_number = prop.id - instance.first_placeholder_id 519 | let dependants = instance.snippet.placeholder_dependants->get(placeholder_number, []) 520 | if !empty(dependants) 521 | let new_content = prop->s:PropContent(lnum) 522 | if new_content ==# instance.cached_placeholders[placeholder_number] | continue | endif 523 | " Store its content and add dependants to list with ref 524 | let instance.cached_placeholders[placeholder_number] = new_content 525 | for dependant in dependants 526 | let instance.dirty_mirrors[dependant] = #{prop: prop, lnum: lnum} 527 | endfor 528 | endif 529 | endfor 530 | endfor 531 | endfor 532 | 533 | if !s:listener_disabled 534 | " If the change was not to active placeholder: Quit current snippet 535 | for _ in range(b:snippet_stack->len() - max_instance_nr - 1) | call s:PopActiveSnippet() | endfor 536 | endif 537 | 538 | call timer_start(0, funcref('s:UpdateMirrors')) 539 | endfunction 540 | 541 | function s:EvalMirror(mirror, instance) abort 542 | for dependency in a:mirror.dependencies 543 | let g:placeholder_values[dependency] = a:instance.cached_placeholders->get(dependency, '') 544 | endfor 545 | let g:m = a:instance.match 546 | echom g:m 547 | return eval(a:mirror.value) 548 | endfunction 549 | 550 | function s:UpdateMirrors(timer) abort 551 | for instance in b:snippet_stack 552 | for [dirty, ref] in instance.dirty_mirrors->items() 553 | let mirror = instance.snippet.mirrors[dirty] 554 | let placeholder = instance.snippet.placeholders[ref.prop.id - instance.first_placeholder_id] 555 | let direction = mirror.order - placeholder.order 556 | let mirror_prop = ref.prop->s:PropFindRelative(instance.first_mirror_id + dirty, 557 | \ ref.lnum, direction) 558 | if mirror_prop->empty() | continue | endif " Might have been deleted 559 | 560 | let text = mirror->s:EvalMirror(instance) 561 | call s:Edit(mirror_prop.lnum, mirror_prop.col, mirror_prop.lnum, 562 | \ mirror_prop.col + mirror_prop.length, [text]) 563 | endfor 564 | let instance.dirty_mirrors = {} 565 | endfor 566 | let s:listener_disabled = 1 567 | try 568 | call listener_flush() 569 | finally 570 | let s:listener_disabled = 0 571 | endtry 572 | endfunction 573 | 574 | function s:OnBufEnter() abort 575 | if exists('b:snippet_stack') | return | endif 576 | let b:snippet_stack = [] 577 | call listener_add(funcref('s:Listener')) 578 | endfunction 579 | 580 | let s:SnippetFiletypes = {-> split(&filetype, '\.') + ['all']} 581 | let s:SourcesForFiletype = {ft -> printf('SnippSnapp/**/%s.snippets', ft)->globpath(&runtimepath, 1, 1)} 582 | 583 | function s:SourceSnippetFile() abort 584 | let file = expand(':p') 585 | let ft = file->fnamemodify(':t:r') 586 | let s:snippets_by_ft[ft] = readfile(file)->s:ParseSnippets() 587 | endfunction 588 | 589 | function s:EnsureSnippetsLoaded(filetype) abort 590 | " TODO Handle multiple files for single filetype 591 | for snippet_file in a:filetype->split('\.')->filter({_, v -> !(s:snippets_by_ft->has_key(v))}) 592 | \ ->s:FlatMap({ft -> s:SourcesForFiletype(ft)}) 593 | execute 'source' snippet_file 594 | endfor 595 | endfunction 596 | 597 | function s:SnippetEdit(mods) abort 598 | let file = s:SnippetFiletypes()->s:FlatMap({ft -> s:SourcesForFiletype(ft)})->get(0, 599 | \ printf('%s/%s.snippets', &runtimepath->split(',')[0], s:SnippetFiletypes()[0])) 600 | execute a:mods 'split' file 601 | augroup snippet_def_buffer 602 | autocmd! 603 | autocmd BufWritePost ++nested source 604 | augroup END 605 | endfunction 606 | 607 | command -bar SnippetEdit call s:SnippetEdit() 608 | 609 | augroup snippet 610 | autocmd! 611 | autocmd BufEnter * call s:OnBufEnter() 612 | autocmd SourceCmd *.snippets call s:SourceSnippetFile() 613 | autocmd FileType * call s:EnsureSnippetsLoaded('') 614 | augroup END 615 | 616 | inoremap