Tue, 10 Dec 2024 15:48:50 +0100
Updated copyright for 2025.
# -*- coding: utf-8 -*- # Copyright (c) 2008 - 2025 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the eric assistant, an alternative autocompletion and calltips system. """ import re from PyQt6.QtCore import QObject from eric7.EricWidgets.EricApplication import ericApp from eric7.Utilities.ModuleParser import PY_SOURCE, Module from .APIsManager import APIsManager, ApisNameProject try: from eric7.QScintilla.Editor import EditorIconId AttributeID = EditorIconId.Attribute AttributeProtectedID = EditorIconId.AttributeProtected AttributePrivateID = EditorIconId.AttributePrivate FromDocumentID = EditorIconId.FromDocument MethodID = EditorIconId.Method MethodProtectedID = EditorIconId.MethodProtected MethodPrivateID = EditorIconId.MethodPrivate except ImportError: # backward compatibility for eric < 24.2 from eric7.QScintilla.Editor import Editor AttributeID = Editor.AttributeID AttributeProtectedID = Editor.AttributeProtectedID AttributePrivateID = Editor.AttributePrivateID FromDocumentID = Editor.FromDocumentID MethodID = Editor.MethodID MethodProtectedID = Editor.MethodProtectedID MethodPrivateID = Editor.MethodPrivateID AcsAPIs = 0x0001 AcsDocument = 0x0002 AcsProject = 0x0004 class Assistant(QObject): """ Class implementing the autocompletion and calltips system. """ def __init__(self, plugin, parent=None): """ Constructor @param plugin reference to the plugin object @type AssistantEricPlugin @param parent parent @type QObject """ QObject.__init__(self, parent) self.__plugin = plugin self.__ui = parent self.__project = ericApp().getObject("Project") self.__viewmanager = ericApp().getObject("ViewManager") self.__pluginManager = ericApp().getObject("PluginManager") self.__apisManager = APIsManager(self.__ui, self) self.__editors = [] self.__lastContext = None self.__lastFullContext = None def activate(self): """ Public method to perform actions upon activation. """ self.__pluginManager.shutdown.connect(self.__shutdown) self.__ui.preferencesChanged.connect(self.__preferencesChanged) self.__viewmanager.editorOpenedEd.connect(self.__editorOpened) self.__viewmanager.editorClosedEd.connect(self.__editorClosed) # preload the project APIs object self.__apisManager.getAPIs(ApisNameProject) for editor in self.__viewmanager.getOpenEditors(): self.__editorOpened(editor) def deactivate(self): """ Public method to perform actions upon deactivation. """ self.__pluginManager.shutdown.disconnect(self.__shutdown) self.__ui.preferencesChanged.disconnect(self.__preferencesChanged) self.__viewmanager.editorOpenedEd.disconnect(self.__editorOpened) self.__viewmanager.editorClosedEd.disconnect(self.__editorClosed) self.__shutdown() def __shutdown(self): """ Private slot to handle the shutdown signal. """ for editor in self.__editors[:]: self.__editorClosed(editor) self.__apisManager.deactivate() def setEnabled( self, key, # noqa: U100 enabled, # noqa: U100 ): """ Public method to enable or disable a feature. @param key feature to set @type str @param enabled flag indicating the status @type bool """ for editor in self.__editors[:]: self.__editorClosed(editor) for editor in self.__viewmanager.getOpenEditors(): self.__editorOpened(editor) def __editorOpened(self, editor): """ Private slot called, when a new editor was opened. @param editor reference to the new editor @type Editor """ if self.__plugin.getPreferences("AutoCompletionEnabled"): self.__setAutoCompletionHook(editor) if self.__plugin.getPreferences("CalltipsEnabled"): self.__setCalltipsHook(editor) if (api := self.__apisManager.getAPIs(ApisNameProject)) is not None: editor.editorSaved.connect(api.editorSaved) self.__editors.append(editor) # preload the api to give the manager a chance to prepare the database language = editor.getApiLanguage() if language: projectType = self.__getProjectType(editor) self.__apisManager.getAPIs(language, projectType=projectType) def __editorClosed(self, editor): """ Private slot called, when an editor was closed. @param editor reference to the editor @type Editor """ if editor in self.__editors: if (api := self.__apisManager.getAPIs(ApisNameProject)) is not None: editor.editorSaved.disconnect(api.editorSaved) self.__editors.remove(editor) if editor.getCompletionListHook("Assistant"): self.__unsetAutoCompletionHook(editor) if editor.getCallTipHook("Assistant"): self.__unsetCalltipsHook(editor) def __preferencesChanged(self): """ Private method to handle a change of the global configuration. """ self.__apisManager.reloadAPIs() def __getProjectType(self, editor): """ Private method to determine the project type to be used. @param editor reference to the editor to check @type Editor @return project type @rtype str """ filename = editor.getFileName() projectType = ( self.__project.getProjectType() if ( self.__project.isOpen() and filename and self.__project.isProjectFile(filename) ) else "" ) return projectType ################################# ## auto-completion methods below ################################# def __recordSelectedContext(self, userListId, txt): """ Private slot to handle the selection from the completion list to record the selected completion context. @param userListId the ID of the user list (should be 1) @type int @param txt the selected text @type str """ from eric7.QScintilla.Editor import EditorAutoCompletionListID if userListId == EditorAutoCompletionListID: lst = txt.split() if len(lst) > 1: self.__lastFullContext = lst[1][1:].split(")")[0] else: self.__lastFullContext = None def __setAutoCompletionHook(self, editor): """ Private method to set the autocompletion hook. @param editor reference to the editor @type Editor """ editor.userListActivated.connect(self.__recordSelectedContext) editor.addCompletionListHook("Assistant", self.getCompletionsList) def __unsetAutoCompletionHook(self, editor): """ Private method to unset the autocompletion hook. @param editor reference to the editor @type Editor """ editor.userListActivated.disconnect(self.__recordSelectedContext) editor.removeCompletionListHook("Assistant") def getCompletionsList(self, editor, context): """ Public method to get a list of possible completions. @param editor reference to the editor object, that called this method @type Editor @param context flag indicating to autocomplete a context @type bool @return list of possible completions @rtype list of str """ language = editor.getApiLanguage() completeFromDocumentOnly = False if language in ["", "Guessed"] or language.startswith("Pygments|"): if self.__plugin.getPreferences("AutoCompletionSource") & AcsDocument: completeFromDocumentOnly = True else: return [] projectType = self.__getProjectType(editor) line, col = editor.getCursorPosition() sep = "" if language and context: wc = re.sub(r"\w", "", editor.wordCharacters()) pat = re.compile(r"\w{0}".format(re.escape(wc))) text = editor.text(line) beg = text[:col] for wsep in editor.getLexer().autoCompletionWordSeparators(): if beg.endswith(wsep): sep = wsep break depth = 0 while col > 0 and not pat.match(text[col - 1]): ch = text[col - 1] if ch == ")": depth = 1 # ignore everything back to the start of the # corresponding parenthesis col -= 1 while col > 0: ch = text[col - 1] if ch == ")": depth += 1 elif ch == "(": depth -= 1 if depth == 0: break col -= 1 elif ch == "(": break col -= 1 word = editor.getWordLeft(line, col) if context and not sep: # no separator was found -> no context completion context = False if context: self.__lastContext = word else: self.__lastContext = None prefix = "" mod = None beg = beg[: col + 1] if context else editor.text(line)[:col] col = len(beg) wseps = editor.getLexer().autoCompletionWordSeparators() if language else [] if wseps: wseps.append(" ") if col > 0 and beg[col - 1] in wseps: col -= 1 else: while col > 0 and beg[col - 1] not in wseps: col -= 1 if col > 0 and beg[col - 1] != " ": col -= 1 prefix = editor.getWordLeft(line, col) if editor.isPyFile(): src = editor.text() fn = editor.getFileName() if fn is None: fn = "" mod = Module("", fn, PY_SOURCE) mod.scan(src) importCompletion = False if editor.isPyFile(): # check, if we are completing a from import statement maxLines = 10 text = editor.text(line).strip() while maxLines and line > 0 and not text.startswith("from"): line -= 1 textm1 = editor.text(line).strip() if not textm1.endswith("\\"): break text = textm1[:-1] + text maxLines -= 1 if text.startswith("from"): tokens = text.split() if len(tokens) >= 3 and tokens[2] == "import": importCompletion = True prefix = tokens[1] col = len(prefix) - 1 wseps = editor.getLexer().autoCompletionWordSeparators() while col >= 0 and prefix[col] not in wseps: col -= 1 if col >= 0: prefix = prefix[col + 1 :] if word == tokens[2]: word = "" if word or importCompletion: completionsList = self.__getCompletions( word, context, prefix, language, projectType, mod, editor, importCompletion, completeFromDocumentOnly, sep, ) if len(completionsList) == 0 and prefix: # searching with prefix didn't return anything, try without completionsList = self.__getCompletions( word, context, "", language, projectType, mod, editor, importCompletion, completeFromDocumentOnly, sep, ) return completionsList return [] def __getCompletions( self, word, context, prefix, language, projectType, module, editor, importCompletion, documentOnly, sep, ): """ Private method to get the list of possible completions. @param word word (or wordpart) to complete @type str @param context flag indicating to autocomplete a context @type bool @param prefix prefix of the word to be completed @type str @param language programming language of the source @type str @param projectType type of the project @type str @param module reference to the scanned module info @type Module @param editor reference to the editor object @type Editor @param importCompletion flag indicating an import completion @type bool @param documentOnly flag indicating to complete from the document only @type bool @param sep separator string @type str @return list of possible completions @rtype list of str """ apiCompletionsList = [] docCompletionsList = [] projectCompletionList = [] if not documentOnly: if self.__plugin.getPreferences("AutoCompletionSource") & AcsAPIs: api = self.__apisManager.getAPIs(language, projectType=projectType) apiCompletionsList = self.__getApiCompletions( api, word, context, prefix, module, editor ) if self.__plugin.getPreferences("AutoCompletionSource") & AcsProject: api = self.__apisManager.getAPIs(ApisNameProject) projectCompletionList = self.__getApiCompletions( api, word, context, prefix, module, editor ) if ( self.__plugin.getPreferences("AutoCompletionSource") & AcsDocument and not importCompletion ): docCompletionsList = self.__getDocumentCompletions( editor, word, context, sep, prefix, module ) completionsList = list( set(apiCompletionsList) .union(set(docCompletionsList)) .union(set(projectCompletionList)) ) return completionsList def __getApiCompletions(self, api, word, context, prefix, module, editor): """ Private method to determine a list of completions from an API object. @param api reference to the API object to be used @type APIsManager.DbAPIs @param word word (or wordpart) to complete @type str @param context flag indicating to autocomplete a context @type bool @param prefix prefix of the word to be completed @type str @param module reference to the scanned module info @type Module @param editor reference to the editor object @type Editor @return list of possible completions @rtype list of str """ completionsList = [] if api is not None: if prefix and module and prefix == "self": line, col = editor.getCursorPosition() for cl in module.classes.values(): if line >= cl.lineno and ( cl.endlineno == -1 or line <= cl.endlineno ): completions = [] for superClass in cl.super: if prefix == word: completions.extend( api.getCompletions( context=superClass, followHierarchy=self.__plugin.getPreferences( "AutoCompletionFollowHierarchy" ), ) ) else: completions.extend( api.getCompletions( start=word, context=superClass, followHierarchy=self.__plugin.getPreferences( "AutoCompletionFollowHierarchy" ), ) ) for completion in completions: if not completion["context"]: entry = completion["completion"] else: entry = "{0} ({1})".format( completion["completion"], completion["context"] ) if entry in completionsList: completionsList.remove(entry) if completion["pictureId"]: entry += "?{0}".format(completion["pictureId"]) else: cont = False regexp = re.compile(re.escape(entry) + r"\?\d{,2}") for comp in completionsList: if regexp.fullmatch(comp): cont = True break if cont: continue if entry not in completionsList: completionsList.append(entry) break elif context: completions = api.getCompletions(context=word) for completion in completions: entry = completion["completion"] if completion["pictureId"]: entry += "?{0}".format(completion["pictureId"]) if entry not in completionsList: completionsList.append(entry) else: if prefix: completions = api.getCompletions(start=word, context=prefix) if not prefix or not completions: # if no completions were returned try without prefix completions = api.getCompletions(start=word) for completion in completions: if not completion["context"]: entry = completion["completion"] else: entry = "{0} ({1})".format( completion["completion"], completion["context"] ) if entry in completionsList: completionsList.remove(entry) if completion["pictureId"]: entry += "?{0}".format(completion["pictureId"]) else: cont = False regexp = re.compile(re.escape(entry) + r"\?\d{,2}") for comp in completionsList: if regexp.fullmatch(comp): cont = True break if cont: continue if entry not in completionsList: completionsList.append(entry) return completionsList def __getDocumentCompletions( self, editor, word, context, sep, prefix, module, doHierarchy=False ): """ Private method to determine autocompletion proposals from the document. @param editor reference to the editor object @type Editor @param word string to be completed @type str @param context flag indicating to autocomplete a context @type bool @param sep separator string @type str @param prefix prefix of the word to be completed @type str @param module reference to the scanned module info @type Module @param doHierarchy flag indicating a hierarchical search @type bool @return list of possible completions @rtype list of str """ completionsList = [] prefixFound = False if prefix and module: line, col = editor.getCursorPosition() if prefix in ["cls", "self"]: prefixFound = True for cl in module.classes.values(): if line >= cl.lineno and ( cl.endlineno == -1 or line <= cl.endlineno ): comps = [] for method in cl.methods.values(): if method.name == "__init__": continue # determine icon type if method.isPrivate(): iconID = MethodPrivateID elif method.isProtected(): iconID = MethodProtectedID else: iconID = MethodID if ( prefix == "cls" and method.modifier == method.Class ) or prefix == "self": comps.append((method.name, cl.name, iconID)) if prefix != "cls": for attribute in cl.attributes.values(): # determine icon type if attribute.isPrivate(): iconID = AttributePrivateID elif attribute.isProtected(): iconID = AttributeProtectedID else: iconID = AttributeID comps.append((attribute.name, cl.name, iconID)) for attribute in cl.globals.values(): # determine icon type if attribute.isPrivate(): iconID = AttributePrivateID elif attribute.isProtected(): iconID = AttributeProtectedID else: iconID = AttributeID comps.append((attribute.name, cl.name, iconID)) if word != prefix: completionsList.extend( [ "{0} ({1})?{2}".format(c[0], c[1], c[2]) for c in comps if c[0].startswith(word) ] ) else: completionsList.extend( [ "{0} ({1})?{2}".format(c[0], c[1], c[2]) for c in comps ] ) for sup in cl.super: if sup in module.classes: if word == prefix: nword = sup else: nword = word completionsList.extend( self.__getDocumentCompletions( editor, nword, context, sep, sup, module, doHierarchy=True, ) ) break else: # possibly completing a named class attribute or method if prefix in module.classes: prefixFound = True cl = module.classes[prefix] comps = [] for method in cl.methods.values(): if method.name == "__init__": continue if doHierarchy or method.modifier in [ method.Class, method.Static, ]: # determine icon type if method.isPrivate(): if doHierarchy: continue iconID = MethodPrivateID elif method.isProtected(): iconID = MethodProtectedID else: iconID = MethodID comps.append((method.name, cl.name, iconID)) for attribute in cl.globals.values(): # determine icon type if attribute.isPrivate(): iconID = AttributePrivateID elif attribute.isProtected(): iconID = AttributeProtectedID else: iconID = AttributeID comps.append((attribute.name, cl.name, iconID)) if word != prefix: completionsList.extend( [ "{0} ({1})?{2}".format(c[0], c[1], c[2]) for c in comps if c[0].startswith(word) ] ) else: completionsList.extend( ["{0} ({1})?{2}".format(c[0], c[1], c[2]) for c in comps] ) for sup in cl.super: if sup in module.classes: if word == prefix: nword = sup else: nword = word completionsList.extend( self.__getDocumentCompletions( editor, nword, context, sep, sup, module, doHierarchy=True, ) ) if not prefixFound: currentPos = editor.currentPosition() if context: word += sep if editor.isUtf8(): sword = word.encode("utf-8") else: sword = word res = editor.findFirstTarget( sword, False, editor.autoCompletionCaseSensitivity(), False, begline=0, begindex=0, ws_=True, ) while res: start, length = editor.getFoundTarget() pos = start + length if pos != currentPos: if context: completion = "" else: completion = word line, index = editor.lineIndexFromPosition(pos) curWord = editor.getWord(line, index, useWordChars=False) completion += curWord[len(completion) :] if ( completion and completion not in completionsList and completion != word ): completionsList.append( "{0}?{1}".format(completion, FromDocumentID) ) res = editor.findNextTarget() completionsList.sort() return completionsList ########################### ## calltips methods below ########################### def __setCalltipsHook(self, editor): """ Private method to set the calltip hook. @param editor reference to the editor @type Editor """ editor.addCallTipHook("Assistant", self.calltips) def __unsetCalltipsHook(self, editor): """ Private method to unset the calltip hook. @param editor reference to the editor @type Editor """ editor.removeCallTipHook("Assistant") def calltips(self, editor, pos, commas): """ Public method to return a list of calltips. @param editor reference to the editor @type Editor @param pos position in the text for the calltip @type int @param commas minimum number of commas contained in the calltip @type int @return list of possible calltips @rtype list of str """ language = editor.getApiLanguage() completeFromDocumentOnly = False if language in ["", "Guessed"] or language.startswith("Pygments|"): if self.__plugin.getPreferences("AutoCompletionSource") & AcsDocument: completeFromDocumentOnly = True else: return [] projectType = self.__getProjectType(editor) line, col = editor.lineIndexFromPosition(pos) wc = re.sub(r"\w", "", editor.wordCharacters()) pat = re.compile(r"\w{0}".format(re.escape(wc))) text = editor.text(line) while col > 0 and not pat.match(text[col - 1]): col -= 1 word = editor.getWordLeft(line, col) prefix = "" mod = None beg = editor.text(line)[:col] col = len(beg) wseps = editor.getLexer().autoCompletionWordSeparators() if language else [] if wseps: if col > 0 and beg[col - 1] in wseps: col -= 1 else: while col > 0 and beg[col - 1] not in wseps + [" ", "\t"]: col -= 1 if col >= 0: col -= 1 prefix = editor.getWordLeft(line, col) if editor.isPyFile(): src = editor.text() fn = editor.getFileName() if fn is None: fn = "" mod = Module("", fn, PY_SOURCE) mod.scan(src) apiCalltips = [] projectCalltips = [] documentCalltips = [] if not completeFromDocumentOnly: if self.__plugin.getPreferences("AutoCompletionSource") & AcsAPIs: api = self.__apisManager.getAPIs(language, projectType=projectType) if api is not None: apiCalltips = self.__getApiCalltips( api, word, commas, prefix, mod, editor ) if self.__plugin.getPreferences("AutoCompletionSource") & AcsProject: api = self.__apisManager.getAPIs(ApisNameProject) projectCalltips = self.__getApiCalltips( api, word, commas, prefix, mod, editor ) if self.__plugin.getPreferences("AutoCompletionSource") & AcsDocument: documentCalltips = self.__getDocumentCalltips(word, prefix, mod, editor) return sorted( set(apiCalltips).union(set(projectCalltips)).union(set(documentCalltips)) ) def __getApiCalltips(self, api, word, commas, prefix, module, editor): """ Private method to determine calltips from APIs. @param api reference to the API object to be used @type APIsManager.DbAPIs @param word function to get calltips for @type str @param commas minimum number of commas contained in the calltip @type int @param prefix prefix of the word to be completed @type str @param module reference to the scanned module info @type Module @param editor reference to the editor object @type Editor @return list of calltips @rtype list of str """ calltips = [] if prefix and module and prefix == "self": line, col = editor.getCursorPosition() for cl in module.classes.values(): if line >= cl.lineno and (cl.endlineno == -1 or line <= cl.endlineno): for superClass in cl.super: calltips.extend( api.getCalltips( word, commas, superClass, None, self.__plugin.getPreferences("CallTipsContextShown"), followHierarchy=self.__plugin.getPreferences( "CallTipsFollowHierarchy" ), ) ) break else: calltips = api.getCalltips( word, commas, self.__lastContext, self.__lastFullContext, self.__plugin.getPreferences("CallTipsContextShown"), ) return calltips def __getDocumentCalltips(self, word, prefix, module, editor, doHierarchy=False): """ Private method to determine calltips from the document. @param word function to get calltips for @type str @param prefix prefix of the word to be completed @type str @param module reference to the scanned module info @type Module @param editor reference to the editor object @type Editor @param doHierarchy flag indicating a hierarchical search @type bool @return list of calltips @rtype list of str """ calltips = [] if module and bool(editor.getLexer()): if prefix: # prefix can be 'self', 'cls' or a class name sep = editor.getLexer().autoCompletionWordSeparators()[0] if prefix in ["self", "cls"]: line, col = editor.getCursorPosition() for cl in module.classes.values(): if line >= cl.lineno and ( cl.endlineno == -1 or line <= cl.endlineno ): if word in cl.methods: method = cl.methods[word] if prefix == "self" or ( prefix == "cls" and method.modifier == method.Class ): calltips.append( "{0}{1}{2}({3})".format( cl.name, sep, word, ", ".join(method.parameters[1:]), ) ) for sup in cl.super: calltips.extend( self.__getDocumentCalltips( word, sup, module, editor, doHierarchy=True ) ) break else: if prefix in module.classes: cl = module.classes[prefix] if word in cl.methods: method = cl.methods[word] if doHierarchy or method.modifier == method.Class: calltips.append( "{0}{1}{2}({3})".format( cl.name, sep, word, ", ".join(method.parameters[1:]), ) ) for sup in cl.super: calltips.extend( self.__getDocumentCalltips( word, sup, module, editor, doHierarchy=True ) ) else: # calltip for a module function or class if word in module.functions: fun = module.functions[word] calltips.append("{0}({1})".format(word, ", ".join(fun.parameters))) elif word in module.classes: cl = module.classes[word] if "__init__" in cl.methods: method = cl.methods["__init__"] calltips.append( "{0}({1})".format(word, ", ".join(method.parameters[1:])) ) return calltips # # eflag: noqa = M834, W605