diff --git a/doc/UltiSnips.txt b/doc/UltiSnips.txt index 533dd3c..8dabc29 100644 --- a/doc/UltiSnips.txt +++ b/doc/UltiSnips.txt @@ -36,6 +36,7 @@ UltiSnips *snippet* *snippets* *UltiSnips* 4.7.1 Replacement String |UltiSnips-replacement-string| 4.7.2 Demos |UltiSnips-demos| 4.8 Clearing snippets |UltiSnips-clearing-snippets| + 4.9 Context snippets |UltiSnips-context-snippets| 5. UltiSnips and Other Plugins |UltiSnips-other-plugins| 5.1 Existing Integrations |UltiSnips-integrations| 5.2 Extending UltiSnips |UltiSnips-extending| @@ -653,6 +654,11 @@ The options currently supported are: > Without this option empty lines in snippets definition will have indentation too. + e Context snippets - With this option expansion of snippet can be + controlled not only by previous characters in line, but by any given + python expression. This option can be specified along with other + options, like 'b'. See |UltiSnips-context-snippets| for more info. + The end line is the 'endsnippet' keyword on a line by itself. > endsnippet @@ -813,11 +819,12 @@ output is ignored. The variables automatically defined in python code are: > - fn - The current filename - path - The complete path to the current file - t - The values of the placeholders, t[1] is the text of ${1}, and so on - snip - UltiSnips.TextObjects.SnippetUtil object instance. Has methods that - simplify indentation handling. + fn - The current filename + path - The complete path to the current file + t - The values of the placeholders, t[1] is the text of ${1}, etc. + snip - UltiSnips.TextObjects.SnippetUtil object instance. Has methods + that simplify indentation handling. + context - Result of context condition. See |UltiSnips-context-snippets|. The 'snip' object provides the following methods: > @@ -1294,6 +1301,95 @@ clearsnippets trigger1 trigger2 ------------------- SNAP ------------------- +4.9 Context snippets *UltiSnips-context-snippets* + +Context snippets can be enabled by using 'e' option in snippet definition. + +In that case snippet should be defined using this syntax: > + + snippet tab_trigger "description" "expression" options + +The 'expression' can be any python expression. If 'expression' evaluates to +'True', then this snippet will be chosen for expansion. The 'expression' must +be wrapped with double-quotes. + +The following python modules are automatically imported into the scope before +'expression' is evaluated: 're', 'os', 'vim', 'string', 'random'. + +Also, the following variables are defined: + 'window' - alias for 'vim.current.window' + 'buffer' - alias for 'vim.current.window.buffer' + 'cursor' - alias for 'vim.current.cursor' + 'line' and 'column' - aliases for cursor position + +Keep in mind, that lines in vim numbered from 1, and lists in python starts +from 0, so to access the current line you need to use 'line-1'. + +------------------- SNIP ------------------- +snippet r "return" "re.match('^\s+if err ', buffer[line-2])" be +return err +endsnippet +------------------- SNAP ------------------- + +That snippet will expand to 'return err' only if the previous line is starting +from 'if err' prefix. + +Note: context snippets prioritized over non-context ones. It makes possible to +use non-context snippets as fallback, if no context matched: + +------------------- SNIP ------------------- +snippet i "if ..." b +if $1 { + $2 +} +endsnippet + +snippet i "if err != nil" "re.match('^\s+[^=]*err\s*:?=', buffer[line-2])" be +if err != nil { + $1 +} +endsnippet +------------------- SNAP ------------------- + +That snippet will expand into 'if err != nil' if previous line will +match 'err :=' prefix, otherwise the default 'if' snippet will be expanded. + +It's a good idea to move context conditions to a separate module, so it can be +used by other UltiSnips users. In that case, module should be imported +using 'global' keyword, like this: + +------------------- SNIP ------------------- +global !p +import my_utils +endglobal + +snippet , "return ..., nil/err" "my_utils.is_return_argument(buffer, line, column)" ie +, `!p if my_utils.is_in_err_condition(): + snip.rv = "err" +else: + snip.rv = "nil"` +endsnippet +------------------- SNAP ------------------- + +That snippet will expand only if the cursor is located in the return statement, +and then it will expand either to 'err' or to 'nil' depending on which 'if' +statement it's located. 'is_return_argument' and 'is_in_err_condition' are +part of custom python module which is called 'my_utils' in this example. + +Context condition can return any value which python can use as condition in +it's 'if' statement, and if it's considered 'True', then snippet will be +expanded. The evaluated value of 'condition' is available in the 'context' +variable inside the snippet: + +------------------- SNIP ------------------- +snippet + "var +=" "re.match('\s*(.*?)\s*:?=', buffer[line-2])" ie +`!p snip.rv = context.group(1)` += $1 +endsnippet +------------------- SNAP ------------------- + +That snippet will expand to 'var1 +=' after line, which begins from 'var1 :='. + + ============================================================================== 5. UltiSnips and Other Plugins *UltiSnips-other-plugins* diff --git a/pythonx/UltiSnips/snippet/definition/_base.py b/pythonx/UltiSnips/snippet/definition/_base.py index 6b73e4e..0c8e096 100644 --- a/pythonx/UltiSnips/snippet/definition/_base.py +++ b/pythonx/UltiSnips/snippet/definition/_base.py @@ -5,6 +5,8 @@ import re +import vim + from UltiSnips import _vim from UltiSnips.compatibility import as_unicode from UltiSnips.indent_util import IndentUtil @@ -43,7 +45,7 @@ class SnippetDefinition(object): _TABS = re.compile(r"^\t*") def __init__(self, priority, trigger, value, description, - options, globals, location): + options, globals, location, context): self._priority = int(priority) self._trigger = as_unicode(trigger) self._value = as_unicode(value) @@ -53,6 +55,8 @@ class SnippetDefinition(object): self._last_re = None self._globals = globals self._location = location + self._context_code = context + self._context = None # Make sure that we actually match our trigger in case we are # immediately expanded. @@ -78,6 +82,31 @@ class SnippetDefinition(object): return match return False + def _context_match(self): + current = vim.current + # skip on empty buffer + if len(current.buffer) == 1 and current.buffer[0] == "": + return + + code = "\n".join([ + 'import re, os, vim, string, random', + '\n'.join(self._globals.get('!p', [])).replace('\r\n', '\n'), + 'context["match"] = ' + self._context_code, + '' + ]) + + context = {'match': False} + locals = { + 'context': context, + 'window': current.window, + 'buffer': current.buffer, + 'line': current.window.cursor[0], + 'column': current.window.cursor[1], + 'cursor': current.window.cursor, + } + exec(code, locals) + return context["match"] + def has_option(self, opt): """Check if the named option is set.""" return opt in self._opts @@ -109,6 +138,11 @@ class SnippetDefinition(object): """Where this snippet was defined.""" return self._location + @property + def context(self): + """The matched context.""" + return self._context + def matches(self, trigger): """Returns True if this snippet matches 'trigger'.""" # If user supplies both "w" and "i", it should perhaps be an @@ -152,6 +186,12 @@ class SnippetDefinition(object): if text_before.strip(' \t') != '': self._matched = '' return False + + if match and self._context_code: + self._context = self._context_match() + if not self.context: + match = False + return match def could_match(self, trigger): @@ -236,7 +276,8 @@ class SnippetDefinition(object): snippet_instance = SnippetInstance( self, parent, initial_text, start, end, visual_content, - last_re=self._last_re, globals=self._globals) + last_re=self._last_re, globals=self._globals, + context=self._context) self.instantiate(snippet_instance, initial_text, indent) snippet_instance.update_textobjects() diff --git a/pythonx/UltiSnips/snippet/definition/snipmate.py b/pythonx/UltiSnips/snippet/definition/snipmate.py index 7f28212..a77046b 100644 --- a/pythonx/UltiSnips/snippet/definition/snipmate.py +++ b/pythonx/UltiSnips/snippet/definition/snipmate.py @@ -15,7 +15,8 @@ class SnipMateSnippetDefinition(SnippetDefinition): def __init__(self, trigger, value, description, location): SnippetDefinition.__init__(self, self.SNIPMATE_SNIPPET_PRIORITY, - trigger, value, description, '', {}, location) + trigger, value, description, '', {}, location, + None) def instantiate(self, snippet_instance, initial_text, indent): parse_and_instantiate(snippet_instance, initial_text, indent) diff --git a/pythonx/UltiSnips/snippet/source/file/_base.py b/pythonx/UltiSnips/snippet/source/file/_base.py index 33754ec..6e639a0 100644 --- a/pythonx/UltiSnips/snippet/source/file/_base.py +++ b/pythonx/UltiSnips/snippet/source/file/_base.py @@ -69,8 +69,12 @@ class SnippetFileSource(SnippetSource): if ft in self._snippets: del self._snippets[ft] del self._extends[ft] - for fn in self._files_for_ft[ft]: - self._parse_snippets(ft, fn) + try: + for fn in self._files_for_ft[ft]: + self._parse_snippets(ft, fn) + except: + del self._files_for_ft[ft] + raise # Now load for the parents for parent_ft in self.get_deep_extends([ft]): if parent_ft != ft and self._needs_update(parent_ft): diff --git a/pythonx/UltiSnips/snippet/source/file/ultisnips.py b/pythonx/UltiSnips/snippet/source/file/ultisnips.py index d503c84..0383997 100644 --- a/pythonx/UltiSnips/snippet/source/file/ultisnips.py +++ b/pythonx/UltiSnips/snippet/source/file/ultisnips.py @@ -65,12 +65,19 @@ def _handle_snippet_or_global(filename, line, lines, python_globals, priority): # Get and strip options if they exist remain = line[len(snip):].strip() words = remain.split() + if len(words) > 2: # second to last word ends with a quote if '"' not in words[-1] and words[-2][-1] == '"': opts = words[-1] remain = remain[:-len(opts) - 1].rstrip() + context = None + if 'e' in opts: + left = remain[:-1].rfind('"') + if left != -1 and left != 0: + context, remain = remain[left:].strip('"'), remain[:left] + # Get and strip description if it exists remain = remain.strip() if len(remain.split()) > 1 and remain[-1] == '"': @@ -103,9 +110,12 @@ def _handle_snippet_or_global(filename, line, lines, python_globals, priority): if snip == 'global': python_globals[trig].append(content) elif snip == 'snippet': - return 'snippet', (UltiSnipsSnippetDefinition(priority, trig, content, - descr, opts, python_globals, - '%s:%i' % (filename, start_line_index)),) + definition = UltiSnipsSnippetDefinition( + priority, trig, content, + descr, opts, python_globals, + '%s:%i' % (filename, start_line_index), + context) + return 'snippet', (definition,) else: return 'error', ("Invalid snippet type: '%s'" % snip, lines.line_index) diff --git a/pythonx/UltiSnips/snippet_manager.py b/pythonx/UltiSnips/snippet_manager.py index 2609c0a..0c6cada 100644 --- a/pythonx/UltiSnips/snippet_manager.py +++ b/pythonx/UltiSnips/snippet_manager.py @@ -205,18 +205,19 @@ class SnippetManager(object): @err_to_scratch_buffer def add_snippet(self, trigger, value, description, - options, ft='all', priority=0): + options, ft='all', priority=0, context=None): """Add a snippet to the list of known snippets of the given 'ft'.""" self._added_snippets_source.add_snippet(ft, UltiSnipsSnippetDefinition(priority, trigger, value, - description, options, {}, 'added')) + description, options, {}, 'added', + context)) @err_to_scratch_buffer - def expand_anon(self, value, trigger='', description='', options=''): + def expand_anon(self, value, trigger='', description='', options='', context=None): """Expand an anonymous snippet right here.""" before = _vim.buf.line_till_cursor snip = UltiSnipsSnippetDefinition(0, trigger, value, description, - options, {}, '') + options, {}, '', context) if not trigger or snip.matches(before): self._do_snippet(snip, before) @@ -574,6 +575,11 @@ class SnippetManager(object): if not before: return False snippets = self._snips(before, False) + if snippets: + # prefer snippets with context if any + snippets_with_context = [s for s in snippets if s.context] + if snippets_with_context: + snippets = snippets_with_context if not snippets: # No snippet found return False diff --git a/pythonx/UltiSnips/text_objects/_snippet_instance.py b/pythonx/UltiSnips/text_objects/_snippet_instance.py index d5dfc64..99b86ed 100644 --- a/pythonx/UltiSnips/text_objects/_snippet_instance.py +++ b/pythonx/UltiSnips/text_objects/_snippet_instance.py @@ -21,7 +21,7 @@ class SnippetInstance(EditableTextObject): # pylint:disable=protected-access def __init__(self, snippet, parent, initial_text, - start, end, visual_content, last_re, globals): + start, end, visual_content, last_re, globals, context): if start is None: start = Position(0, 0) if end is None: @@ -29,7 +29,7 @@ class SnippetInstance(EditableTextObject): self.snippet = snippet self._cts = 0 - self.locals = {'match': last_re} + self.locals = {'match': last_re, 'context': context} self.globals = globals self.visual_content = visual_content diff --git a/syntax/snippets.vim b/syntax/snippets.vim index aa2da1b..bebd041 100644 --- a/syntax/snippets.vim +++ b/syntax/snippets.vim @@ -78,7 +78,7 @@ syn match snipSnippetTrigger ,".\{-}"\ze\%(\s\+"\%(\s*\S\)\@=[^"]*\%("\s\+[^"[:s syn match snipSnippetTriggerInvalid ,\S\@=.\{-}\S\ze\%(\s\+"[^"]*\%("\s\+[^"[:space:]]\+\s*\|"\s*\)\=\|\s*\)$, contained nextgroup=snipSnippetDocString skipwhite syn match snipSnippetDocString ,"[^"]*\%("\ze\s*\%(\s[^"[:space:]]\+\s*\)\=\)\=$, contained nextgroup=snipSnippetOptions skipwhite syn match snipSnippetOptions ,\S\+, contained contains=snipSnippetOptionFlag -syn match snipSnippetOptionFlag ,[biwrts], contained +syn match snipSnippetOptionFlag ,[biwrtsmx], contained " Command substitution {{{4 diff --git a/test/test_ContextSnippets.py b/test/test_ContextSnippets.py new file mode 100644 index 0000000..eea4086 --- /dev/null +++ b/test/test_ContextSnippets.py @@ -0,0 +1,127 @@ +from test.vim_test_case import VimTestCase as _VimTest +from test.constant import * + + +class ContextSnippets_SimpleSnippet(_VimTest): + files = { 'us/all.snippets': r""" + snippet a "desc" "True" e + abc + endsnippet + """} + keys = 'a' + EX + wanted = 'abc' + + +class ContextSnippets_ExpandOnTrue(_VimTest): + files = { 'us/all.snippets': r""" + global !p + def check_context(): + return True + endglobal + + snippet a "desc" "check_context()" e + abc + endsnippet + """} + keys = 'a' + EX + wanted = 'abc' + + +class ContextSnippets_DoNotExpandOnFalse(_VimTest): + files = { 'us/all.snippets': r""" + global !p + def check_context(): + return False + endglobal + + snippet a "desc" "check_context()" e + abc + endsnippet + """} + keys = 'a' + EX + wanted = keys + + + +class ContextSnippets_UseContext(_VimTest): + files = { 'us/all.snippets': r""" + global !p + def wrap(ins): + return "< " + ins + " >" + endglobal + + snippet a "desc" "wrap(buffer[line-1])" e + { `!p snip.rv = context` } + endsnippet + """} + keys = 'a' + EX + wanted = '{ < a > }' + + +class ContextSnippets_SnippetPriority(_VimTest): + files = { 'us/all.snippets': r""" + snippet i "desc" "re.search('err :=', buffer[line-2])" e + if err != nil { + ${1:// pass} + } + endsnippet + + snippet i + if ${1:true} { + ${2:// pass} + } + endsnippet + """} + + keys = r""" + err := some_call() + i""" + EX + JF + """ + i""" + EX + wanted = r""" + err := some_call() + if err != nil { + // pass + } + if true { + // pass + }""" + + +class ContextSnippets_PriorityKeyword(_VimTest): + files = { 'us/all.snippets': r""" + snippet i "desc" "True" e + a + endsnippet + + priority 100 + snippet i + b + endsnippet + """} + + keys = "i" + EX + wanted = "b" + + +class ContextSnippets_ReportError(_VimTest): + files = { 'us/all.snippets': r""" + snippet e "desc" "Tru" e + error + endsnippet + """} + + keys = "e" + EX + wanted = "e" + EX + expected_error = r"NameError: name 'Tru' is not defined" + + +class ContextSnippets_ReportErrorOnIndexOutOfRange(_VimTest): + files = { 'us/all.snippets': r""" + snippet e "desc" "buffer[123]" e + error + endsnippet + """} + + keys = "e" + EX + wanted = "e" + EX + expected_error = r"IndexError: line number out of range" diff --git a/test/test_UltiSnipFunc.py b/test/test_UltiSnipFunc.py index bd60626..b266b9a 100644 --- a/test/test_UltiSnipFunc.py +++ b/test/test_UltiSnipFunc.py @@ -159,7 +159,8 @@ class MySnippetSource(SnippetSource): if before.endswith('blumba'): return [ UltiSnipsSnippetDefinition( - -100, "blumba", "this is a dynamic snippet", "", "", {}, "blub") + -100, "blumba", "this is a dynamic snippet", "", "", {}, "blub", + None) ] return [] """)