1"pythoncomplete.vim - Omni Completion for python
2" Maintainer: <vacancy>
3" Previous Maintainer: Aaron Griffin <[email protected]>
4" Version: 0.9
5" Last Updated: 2020 Oct 9
6"
7" Changes
8" TODO:
9" 'info' item output can use some formatting work
10" Add an "unsafe eval" mode, to allow for return type evaluation
11" Complete basic syntax along with import statements
12"   i.e. "import url<c-x,c-o>"
13" Continue parsing on invalid line??
14"
15" v 0.9
16"   * Fixed docstring parsing for classes and functions
17"   * Fixed parsing of *args and **kwargs type arguments
18"   * Better function param parsing to handle things like tuples and
19"     lambda defaults args
20"
21" v 0.8
22"   * Fixed an issue where the FIRST assignment was always used instead of
23"   using a subsequent assignment for a variable
24"   * Fixed a scoping issue when working inside a parameterless function
25"
26"
27" v 0.7
28"   * Fixed function list sorting (_ and __ at the bottom)
29"   * Removed newline removal from docs.  It appears vim handles these better in
30"   recent patches
31"
32" v 0.6:
33"   * Fixed argument completion
34"   * Removed the 'kind' completions, as they are better indicated
35"   with real syntax
36"   * Added tuple assignment parsing (whoops, that was forgotten)
37"   * Fixed import handling when flattening scope
38"
39" v 0.5:
40" Yeah, I skipped a version number - 0.4 was never public.
41"  It was a bugfix version on top of 0.3.  This is a complete
42"  rewrite.
43"
44
45if !has('python')
46    echo "Error: Required vim compiled with +python"
47    finish
48endif
49
50function! pythoncomplete#Complete(findstart, base)
51    "findstart = 1 when we need to get the text length
52    if a:findstart == 1
53        let line = getline('.')
54        let idx = col('.')
55        while idx > 0
56            let idx -= 1
57            let c = line[idx]
58            if c =~ '\w'
59                continue
60            elseif ! c =~ '\.'
61                let idx = -1
62                break
63            else
64                break
65            endif
66        endwhile
67
68        return idx
69    "findstart = 0 when we need to return the list of completions
70    else
71        "vim no longer moves the cursor upon completion... fix that
72        let line = getline('.')
73        let idx = col('.')
74        let cword = ''
75        while idx > 0
76            let idx -= 1
77            let c = line[idx]
78            if c =~ '\w' || c =~ '\.'
79                let cword = c . cword
80                continue
81            elseif strlen(cword) > 0 || idx == 0
82                break
83            endif
84        endwhile
85        execute "python vimcomplete('" . escape(cword, "'") . "', '" . escape(a:base, "'") . "')"
86        return g:pythoncomplete_completions
87    endif
88endfunction
89
90function! s:DefPython()
91python << PYTHONEOF
92import sys, tokenize, cStringIO, types
93from token import NAME, DEDENT, NEWLINE, STRING
94
95debugstmts=[]
96def dbg(s): debugstmts.append(s)
97def showdbg():
98    for d in debugstmts: print "DBG: %s " % d
99
100def vimcomplete(context,match):
101    global debugstmts
102    debugstmts = []
103    try:
104        import vim
105        def complsort(x,y):
106            try:
107                xa = x['abbr']
108                ya = y['abbr']
109                if xa[0] == '_':
110                    if xa[1] == '_' and ya[0:2] == '__':
111                        return xa > ya
112                    elif ya[0:2] == '__':
113                        return -1
114                    elif y[0] == '_':
115                        return xa > ya
116                    else:
117                        return 1
118                elif ya[0] == '_':
119                    return -1
120                else:
121                   return xa > ya
122            except:
123                return 0
124        cmpl = Completer()
125        cmpl.evalsource('\n'.join(vim.current.buffer),vim.eval("line('.')"))
126        all = cmpl.get_completions(context,match)
127        all.sort(complsort)
128        dictstr = '['
129        # have to do this for double quoting
130        for cmpl in all:
131            dictstr += '{'
132            for x in cmpl: dictstr += '"%s":"%s",' % (x,cmpl[x])
133            dictstr += '"icase":0},'
134        if dictstr[-1] == ',': dictstr = dictstr[:-1]
135        dictstr += ']'
136        #dbg("dict: %s" % dictstr)
137        vim.command("silent let g:pythoncomplete_completions = %s" % dictstr)
138        #dbg("Completion dict:\n%s" % all)
139    except vim.error:
140        dbg("VIM Error: %s" % vim.error)
141
142class Completer(object):
143    def __init__(self):
144       self.compldict = {}
145       self.parser = PyParser()
146
147    def evalsource(self,text,line=0):
148        sc = self.parser.parse(text,line)
149        src = sc.get_code()
150        dbg("source: %s" % src)
151        try: exec(src) in self.compldict
152        except: dbg("parser: %s, %s" % (sys.exc_info()[0],sys.exc_info()[1]))
153        for l in sc.locals:
154            try: exec(l) in self.compldict
155            except: dbg("locals: %s, %s [%s]" % (sys.exc_info()[0],sys.exc_info()[1],l))
156
157    def _cleanstr(self,doc):
158        return doc.replace('"',' ').replace("'",' ')
159
160    def get_arguments(self,func_obj):
161        def _ctor(obj):
162            try: return class_ob.__init__.im_func
163            except AttributeError:
164                for base in class_ob.__bases__:
165                    rc = _find_constructor(base)
166                    if rc is not None: return rc
167            return None
168
169        arg_offset = 1
170        if type(func_obj) == types.ClassType: func_obj = _ctor(func_obj)
171        elif type(func_obj) == types.MethodType: func_obj = func_obj.im_func
172        else: arg_offset = 0
173
174        arg_text=''
175        if type(func_obj) in [types.FunctionType, types.LambdaType]:
176            try:
177                cd = func_obj.func_code
178                real_args = cd.co_varnames[arg_offset:cd.co_argcount]
179                defaults = func_obj.func_defaults or ''
180                defaults = map(lambda name: "=%s" % name, defaults)
181                defaults = [""] * (len(real_args)-len(defaults)) + defaults
182                items = map(lambda a,d: a+d, real_args, defaults)
183                if func_obj.func_code.co_flags & 0x4:
184                    items.append("...")
185                if func_obj.func_code.co_flags & 0x8:
186                    items.append("***")
187                arg_text = (','.join(items)) + ')'
188
189            except:
190                dbg("arg completion: %s: %s" % (sys.exc_info()[0],sys.exc_info()[1]))
191                pass
192        if len(arg_text) == 0:
193            # The doc string sometimes contains the function signature
194            #  this works for a lot of C modules that are part of the
195            #  standard library
196            doc = func_obj.__doc__
197            if doc:
198                doc = doc.lstrip()
199                pos = doc.find('\n')
200                if pos > 0:
201                    sigline = doc[:pos]
202                    lidx = sigline.find('(')
203                    ridx = sigline.find(')')
204                    if lidx > 0 and ridx > 0:
205                        arg_text = sigline[lidx+1:ridx] + ')'
206        if len(arg_text) == 0: arg_text = ')'
207        return arg_text
208
209    def get_completions(self,context,match):
210        dbg("get_completions('%s','%s')" % (context,match))
211        stmt = ''
212        if context: stmt += str(context)
213        if match: stmt += str(match)
214        try:
215            result = None
216            all = {}
217            ridx = stmt.rfind('.')
218            if len(stmt) > 0 and stmt[-1] == '(':
219                result = eval(_sanitize(stmt[:-1]), self.compldict)
220                doc = result.__doc__
221                if doc is None: doc = ''
222                args = self.get_arguments(result)
223                return [{'word':self._cleanstr(args),'info':self._cleanstr(doc)}]
224            elif ridx == -1:
225                match = stmt
226                all = self.compldict
227            else:
228                match = stmt[ridx+1:]
229                stmt = _sanitize(stmt[:ridx])
230                result = eval(stmt, self.compldict)
231                all = dir(result)
232
233            dbg("completing: stmt:%s" % stmt)
234            completions = []
235
236            try: maindoc = result.__doc__
237            except: maindoc = ' '
238            if maindoc is None: maindoc = ' '
239            for m in all:
240                if m == "_PyCmplNoType": continue #this is internal
241                try:
242                    dbg('possible completion: %s' % m)
243                    if m.find(match) == 0:
244                        if result is None: inst = all[m]
245                        else: inst = getattr(result,m)
246                        try: doc = inst.__doc__
247                        except: doc = maindoc
248                        typestr = str(inst)
249                        if doc is None or doc == '': doc = maindoc
250
251                        wrd = m[len(match):]
252                        c = {'word':wrd, 'abbr':m,  'info':self._cleanstr(doc)}
253                        if "function" in typestr:
254                            c['word'] += '('
255                            c['abbr'] += '(' + self._cleanstr(self.get_arguments(inst))
256                        elif "method" in typestr:
257                            c['word'] += '('
258                            c['abbr'] += '(' + self._cleanstr(self.get_arguments(inst))
259                        elif "module" in typestr:
260                            c['word'] += '.'
261                        elif "class" in typestr:
262                            c['word'] += '('
263                            c['abbr'] += '('
264                        completions.append(c)
265                except:
266                    i = sys.exc_info()
267                    dbg("inner completion: %s,%s [stmt='%s']" % (i[0],i[1],stmt))
268            return completions
269        except:
270            i = sys.exc_info()
271            dbg("completion: %s,%s [stmt='%s']" % (i[0],i[1],stmt))
272            return []
273
274class Scope(object):
275    def __init__(self,name,indent,docstr=''):
276        self.subscopes = []
277        self.docstr = docstr
278        self.locals = []
279        self.parent = None
280        self.name = name
281        self.indent = indent
282
283    def add(self,sub):
284        #print 'push scope: [%s@%s]' % (sub.name,sub.indent)
285        sub.parent = self
286        self.subscopes.append(sub)
287        return sub
288
289    def doc(self,str):
290        """ Clean up a docstring """
291        d = str.replace('\n',' ')
292        d = d.replace('\t',' ')
293        while d.find('  ') > -1: d = d.replace('  ',' ')
294        while d[0] in '"\'\t ': d = d[1:]
295        while d[-1] in '"\'\t ': d = d[:-1]
296        dbg("Scope(%s)::docstr = %s" % (self,d))
297        self.docstr = d
298
299    def local(self,loc):
300        self._checkexisting(loc)
301        self.locals.append(loc)
302
303    def copy_decl(self,indent=0):
304        """ Copy a scope's declaration only, at the specified indent level - not local variables """
305        return Scope(self.name,indent,self.docstr)
306
307    def _checkexisting(self,test):
308        "Convienance function... keep out duplicates"
309        if test.find('=') > -1:
310            var = test.split('=')[0].strip()
311            for l in self.locals:
312                if l.find('=') > -1 and var == l.split('=')[0].strip():
313                    self.locals.remove(l)
314
315    def get_code(self):
316        str = ""
317        if len(self.docstr) > 0: str += '"""'+self.docstr+'"""\n'
318        for l in self.locals:
319            if l.startswith('import'): str += l+'\n'
320        str += 'class _PyCmplNoType:\n    def __getattr__(self,name):\n        return None\n'
321        for sub in self.subscopes:
322            str += sub.get_code()
323        for l in self.locals:
324            if not l.startswith('import'): str += l+'\n'
325
326        return str
327
328    def pop(self,indent):
329        #print 'pop scope: [%s] to [%s]' % (self.indent,indent)
330        outer = self
331        while outer.parent != None and outer.indent >= indent:
332            outer = outer.parent
333        return outer
334
335    def currentindent(self):
336        #print 'parse current indent: %s' % self.indent
337        return '    '*self.indent
338
339    def childindent(self):
340        #print 'parse child indent: [%s]' % (self.indent+1)
341        return '    '*(self.indent+1)
342
343class Class(Scope):
344    def __init__(self, name, supers, indent, docstr=''):
345        Scope.__init__(self,name,indent, docstr)
346        self.supers = supers
347    def copy_decl(self,indent=0):
348        c = Class(self.name,self.supers,indent, self.docstr)
349        for s in self.subscopes:
350            c.add(s.copy_decl(indent+1))
351        return c
352    def get_code(self):
353        str = '%sclass %s' % (self.currentindent(),self.name)
354        if len(self.supers) > 0: str += '(%s)' % ','.join(self.supers)
355        str += ':\n'
356        if len(self.docstr) > 0: str += self.childindent()+'"""'+self.docstr+'"""\n'
357        if len(self.subscopes) > 0:
358            for s in self.subscopes: str += s.get_code()
359        else:
360            str += '%spass\n' % self.childindent()
361        return str
362
363
364class Function(Scope):
365    def __init__(self, name, params, indent, docstr=''):
366        Scope.__init__(self,name,indent, docstr)
367        self.params = params
368    def copy_decl(self,indent=0):
369        return Function(self.name,self.params,indent, self.docstr)
370    def get_code(self):
371        str = "%sdef %s(%s):\n" % \
372            (self.currentindent(),self.name,','.join(self.params))
373        if len(self.docstr) > 0: str += self.childindent()+'"""'+self.docstr+'"""\n'
374        str += "%spass\n" % self.childindent()
375        return str
376
377class PyParser:
378    def __init__(self):
379        self.top = Scope('global',0)
380        self.scope = self.top
381        self.parserline = 0
382
383    def _parsedotname(self,pre=None):
384        #returns (dottedname, nexttoken)
385        name = []
386        if pre is None:
387            tokentype, token, indent = self.next()
388            if tokentype != NAME and token != '*':
389                return ('', token)
390        else: token = pre
391        name.append(token)
392        while True:
393            tokentype, token, indent = self.next()
394            if token != '.': break
395            tokentype, token, indent = self.next()
396            if tokentype != NAME: break
397            name.append(token)
398        return (".".join(name), token)
399
400    def _parseimportlist(self):
401        imports = []
402        while True:
403            name, token = self._parsedotname()
404            if not name: break
405            name2 = ''
406            if token == 'as': name2, token = self._parsedotname()
407            imports.append((name, name2))
408            while token != "," and "\n" not in token:
409                tokentype, token, indent = self.next()
410            if token != ",": break
411        return imports
412
413    def _parenparse(self):
414        name = ''
415        names = []
416        level = 1
417        while True:
418            tokentype, token, indent = self.next()
419            if token in (')', ',') and level == 1:
420                if '=' not in name: name = name.replace(' ', '')
421                names.append(name.strip())
422                name = ''
423            if token == '(':
424                level += 1
425                name += "("
426            elif token == ')':
427                level -= 1
428                if level == 0: break
429                else: name += ")"
430            elif token == ',' and level == 1:
431                pass
432            else:
433                name += "%s " % str(token)
434        return names
435
436    def _parsefunction(self,indent):
437        self.scope=self.scope.pop(indent)
438        tokentype, fname, ind = self.next()
439        if tokentype != NAME: return None
440
441        tokentype, open, ind = self.next()
442        if open != '(': return None
443        params=self._parenparse()
444
445        tokentype, colon, ind = self.next()
446        if colon != ':': return None
447
448        return Function(fname,params,indent)
449
450    def _parseclass(self,indent):
451        self.scope=self.scope.pop(indent)
452        tokentype, cname, ind = self.next()
453        if tokentype != NAME: return None
454
455        super = []
456        tokentype, next, ind = self.next()
457        if next == '(':
458            super=self._parenparse()
459        elif next != ':': return None
460
461        return Class(cname,super,indent)
462
463    def _parseassignment(self):
464        assign=''
465        tokentype, token, indent = self.next()
466        if tokentype == tokenize.STRING or token == 'str':
467            return '""'
468        elif token == '(' or token == 'tuple':
469            return '()'
470        elif token == '[' or token == 'list':
471            return '[]'
472        elif token == '{' or token == 'dict':
473            return '{}'
474        elif tokentype == tokenize.NUMBER:
475            return '0'
476        elif token == 'open' or token == 'file':
477            return 'file'
478        elif token == 'None':
479            return '_PyCmplNoType()'
480        elif token == 'type':
481            return 'type(_PyCmplNoType)' #only for method resolution
482        else:
483            assign += token
484            level = 0
485            while True:
486                tokentype, token, indent = self.next()
487                if token in ('(','{','['):
488                    level += 1
489                elif token in (']','}',')'):
490                    level -= 1
491                    if level == 0: break
492                elif level == 0:
493                    if token in (';','\n'): break
494                    assign += token
495        return "%s" % assign
496
497    def next(self):
498        type, token, (lineno, indent), end, self.parserline = self.gen.next()
499        if lineno == self.curline:
500            #print 'line found [%s] scope=%s' % (line.replace('\n',''),self.scope.name)
501            self.currentscope = self.scope
502        return (type, token, indent)
503
504    def _adjustvisibility(self):
505        newscope = Scope('result',0)
506        scp = self.currentscope
507        while scp != None:
508            if type(scp) == Function:
509                slice = 0
510                #Handle 'self' params
511                if scp.parent != None and type(scp.parent) == Class:
512                    slice = 1
513                    newscope.local('%s = %s' % (scp.params[0],scp.parent.name))
514                for p in scp.params[slice:]:
515                    i = p.find('=')
516                    if len(p) == 0: continue
517                    pvar = ''
518                    ptype = ''
519                    if i == -1:
520                        pvar = p
521                        ptype = '_PyCmplNoType()'
522                    else:
523                        pvar = p[:i]
524                        ptype = _sanitize(p[i+1:])
525                    if pvar.startswith('**'):
526                        pvar = pvar[2:]
527                        ptype = '{}'
528                    elif pvar.startswith('*'):
529                        pvar = pvar[1:]
530                        ptype = '[]'
531
532                    newscope.local('%s = %s' % (pvar,ptype))
533
534            for s in scp.subscopes:
535                ns = s.copy_decl(0)
536                newscope.add(ns)
537            for l in scp.locals: newscope.local(l)
538            scp = scp.parent
539
540        self.currentscope = newscope
541        return self.currentscope
542
543    #p.parse(vim.current.buffer[:],vim.eval("line('.')"))
544    def parse(self,text,curline=0):
545        self.curline = int(curline)
546        buf = cStringIO.StringIO(''.join(text) + '\n')
547        self.gen = tokenize.generate_tokens(buf.readline)
548        self.currentscope = self.scope
549
550        try:
551            freshscope=True
552            while True:
553                tokentype, token, indent = self.next()
554                #dbg( 'main: token=[%s] indent=[%s]' % (token,indent))
555
556                if tokentype == DEDENT or token == "pass":
557                    self.scope = self.scope.pop(indent)
558                elif token == 'def':
559                    func = self._parsefunction(indent)
560                    if func is None:
561                        print "function: syntax error..."
562                        continue
563                    dbg("new scope: function")
564                    freshscope = True
565                    self.scope = self.scope.add(func)
566                elif token == 'class':
567                    cls = self._parseclass(indent)
568                    if cls is None:
569                        print "class: syntax error..."
570                        continue
571                    freshscope = True
572                    dbg("new scope: class")
573                    self.scope = self.scope.add(cls)
574
575                elif token == 'import':
576                    imports = self._parseimportlist()
577                    for mod, alias in imports:
578                        loc = "import %s" % mod
579                        if len(alias) > 0: loc += " as %s" % alias
580                        self.scope.local(loc)
581                    freshscope = False
582                elif token == 'from':
583                    mod, token = self._parsedotname()
584                    if not mod or token != "import":
585                        print "from: syntax error..."
586                        continue
587                    names = self._parseimportlist()
588                    for name, alias in names:
589                        loc = "from %s import %s" % (mod,name)
590                        if len(alias) > 0: loc += " as %s" % alias
591                        self.scope.local(loc)
592                    freshscope = False
593                elif tokentype == STRING:
594                    if freshscope: self.scope.doc(token)
595                elif tokentype == NAME:
596                    name,token = self._parsedotname(token)
597                    if token == '=':
598                        stmt = self._parseassignment()
599                        dbg("parseassignment: %s = %s" % (name, stmt))
600                        if stmt != None:
601                            self.scope.local("%s = %s" % (name,stmt))
602                    freshscope = False
603        except StopIteration: #thrown on EOF
604            pass
605        except:
606            dbg("parse error: %s, %s @ %s" %
607                (sys.exc_info()[0], sys.exc_info()[1], self.parserline))
608        return self._adjustvisibility()
609
610def _sanitize(str):
611    val = ''
612    level = 0
613    for c in str:
614        if c in ('(','{','['):
615            level += 1
616        elif c in (']','}',')'):
617            level -= 1
618        elif level == 0:
619            val += c
620    return val
621
622sys.path.extend(['.','..'])
623PYTHONEOF
624endfunction
625
626call s:DefPython()
627" vim: set et ts=4:
628