From ee0b4f8103a49946dc308c9c56dd7226528a4966 Mon Sep 17 00:00:00 2001
From: Martyn Gigg <martyn.gigg@gmail.com>
Date: Tue, 16 Jan 2018 11:39:44 +0000
Subject: [PATCH] Implement autocompletion and calltips in the editor.

Refs #21251
---
 .../workbench/workbench/plugins/editor.py     |  2 +-
 .../mantidqt/widgets/codeeditor/execution.py  | 94 +++++++++++++++++--
 .../widgets/codeeditor/inputsplitter.py       |  1 -
 .../widgets/codeeditor/interpreter.py         |  3 +
 .../widgets/codeeditor/test/test_execution.py | 26 +++--
 .../mantidqt/widgets/src/_widgetscore.sip     | 11 +++
 .../inc/MantidQtWidgets/Common/ScriptEditor.h |  6 +-
 qt/widgets/common/src/ScriptEditor.cpp        |  7 +-
 8 files changed, 120 insertions(+), 30 deletions(-)

diff --git a/qt/applications/workbench/workbench/plugins/editor.py b/qt/applications/workbench/workbench/plugins/editor.py
index bcadfd8b025..c8ec86820d5 100644
--- a/qt/applications/workbench/workbench/plugins/editor.py
+++ b/qt/applications/workbench/workbench/plugins/editor.py
@@ -58,7 +58,7 @@ class MultiFileEditor(PluginWidget):
                                         shortcut="Ctrl+Return",
                                         shortcut_context=Qt.ApplicationShortcut)
         self.abort_action = create_action(self, "Abort",
-                                        on_triggered=self.editors.abort_current)
+                                          on_triggered=self.editors.abort_current)
 
         self.editor_actions = [self.run_action, self.abort_action]
 
diff --git a/qt/python/mantidqt/widgets/codeeditor/execution.py b/qt/python/mantidqt/widgets/codeeditor/execution.py
index 81792f56c47..7f5f78bc7ab 100644
--- a/qt/python/mantidqt/widgets/codeeditor/execution.py
+++ b/qt/python/mantidqt/widgets/codeeditor/execution.py
@@ -18,15 +18,85 @@ from __future__ import (absolute_import, unicode_literals)
 
 # std imports
 import ctypes
+import inspect
 import time
 
 # 3rdparty imports
 from qtpy.QtCore import QObject, Signal
+from six import PY2, iteritems
 
 # local imports
 from mantidqt.widgets.codeeditor.inputsplitter import InputSplitter
 from mantidqt.utils.async import AsyncTask
 
+if PY2:
+    from inspect import getargspec as getfullargspec
+else:
+    from inspect import getfullargspec
+
+
+def get_function_spec(func):
+    """Get the python function signature for the given function object. First
+    the args are inspected followed by varargs, which are set by some modules,
+    e.g. mantid.simpleapi algorithm functions
+
+    :param func: A Python function object
+    :returns: A string containing the function specification
+    :
+    """
+    try:
+        argspec = getfullargspec(func)
+    except TypeError:
+        return ''
+    # mantid algorithm functions have varargs set not args
+    args = argspec[0]
+    if args:
+        # For methods strip the self argument
+        if hasattr(func, 'im_func'):
+            args = args[1:]
+        defs = argspec[3]
+    elif argspec[1] is not None:
+        # Get from varargs/keywords
+        arg_str = argspec[1].strip().lstrip('\b')
+        defs = []
+        # Keyword args
+        kwargs = argspec[2]
+        if kwargs is not None:
+            kwargs = kwargs.strip().lstrip('\b\b')
+            if kwargs == 'kwargs':
+                kwargs = '**' + kwargs + '=None'
+            arg_str += ',%s' % kwargs
+        # Any default argument appears in the string
+        # on the rhs of an equal
+        for arg in arg_str.split(','):
+            arg = arg.strip()
+            if '=' in arg:
+                arg_token = arg.split('=')
+                args.append(arg_token[0])
+                defs.append(arg_token[1])
+            else:
+                args.append(arg)
+        if len(defs) == 0:
+            defs = None
+    else:
+        return ''
+
+    if defs is None:
+        calltip = ','.join(args)
+        calltip = '(' + calltip + ')'
+    else:
+        # The defaults list contains the default values for the last n arguments
+        diff = len(args) - len(defs)
+        calltip = ''
+        for index in range(len(args) - 1, -1, -1):
+            def_index = index - diff
+            if def_index >= 0:
+                calltip = '[' + args[index] + '],' + calltip
+            else:
+                calltip = args[index] + "," + calltip
+        calltip = '(' + calltip.rstrip(',') + ')'
+    return calltip
+
 
 class PythonCodeExecution(QObject):
     """Provides the ability to execute arbitrary
@@ -41,7 +111,7 @@ class PythonCodeExecution(QObject):
         """Initialize the object"""
         super(PythonCodeExecution, self).__init__()
 
-        self._globals_ns, self._locals_ns = None, None
+        self._globals_ns = None
         self._task = None
 
         self.reset_context()
@@ -50,10 +120,6 @@ class PythonCodeExecution(QObject):
     def globals_ns(self):
         return self._globals_ns
 
-    @property
-    def locals_ns(self):
-        return self._locals_ns
-
     def abort(self):
         """Cancel an asynchronous execution"""
         # Implementation is based on
@@ -97,11 +163,25 @@ class PythonCodeExecution(QObject):
             sig_progress.emit(block.lineno)
             # compile so we can set the filename
             code_obj = compile(block.code_str, filename, mode='exec')
-            exec(code_obj, self.globals_ns, self.locals_ns)
+            exec(code_obj, self.globals_ns, self.globals_ns)
+
+    def generate_calltips(self):
+        """
+        Return a list of calltips for the current global scope. This is currently
+        very basic and only inspects the available functions and builtins at the current scope.
+
+        :return: A list of strings giving calltips for each global callable
+        """
+        calltips = []
+        for name, attr in iteritems(self._globals_ns):
+            if inspect.isfunction(attr) or inspect.isbuiltin(attr):
+                calltips.append(name + get_function_spec(attr))
+
+        return calltips
 
     def reset_context(self):
         # create new context for execution
-        self._globals_ns, self._locals_ns = {}, {}
+        self._globals_ns, self._namespace = {}, {}
 
     # --------------------- Callbacks -------------------------------
     def _on_success(self, task_result):
diff --git a/qt/python/mantidqt/widgets/codeeditor/inputsplitter.py b/qt/python/mantidqt/widgets/codeeditor/inputsplitter.py
index f38b3913cba..738a33a264c 100644
--- a/qt/python/mantidqt/widgets/codeeditor/inputsplitter.py
+++ b/qt/python/mantidqt/widgets/codeeditor/inputsplitter.py
@@ -115,4 +115,3 @@ class InputSplitter(IPyInputSplitter):
 
         # General fallback - accept more code
         return True
-
diff --git a/qt/python/mantidqt/widgets/codeeditor/interpreter.py b/qt/python/mantidqt/widgets/codeeditor/interpreter.py
index 037c8fad6e7..40aafa85b2a 100644
--- a/qt/python/mantidqt/widgets/codeeditor/interpreter.py
+++ b/qt/python/mantidqt/widgets/codeeditor/interpreter.py
@@ -176,6 +176,8 @@ class PythonFileInterpreter(QWidget):
         else:
             editor.setModified(True)
 
+        editor.enableAutoCompletion(CodeEditor.AcsAll)
+
 
 class PythonFileInterpreterPresenter(QObject):
     """Presenter part of MVP to control actions on the editor"""
@@ -232,6 +234,7 @@ class PythonFileInterpreterPresenter(QObject):
         return code_str, line_from
 
     def _on_exec_success(self, task_result):
+        self.view.editor.updateCompletionAPI(self.model.generate_calltips())
         self._finish(success=True, elapsed_time=task_result.elapsed_time)
 
     def _on_exec_error(self, task_error):
diff --git a/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py b/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py
index 5e08558d5c0..f8effdbb870 100644
--- a/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py
+++ b/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py
@@ -56,28 +56,24 @@ class PythonCodeExecutionTest(unittest.TestCase):
     def test_default_construction_yields_empty_context(self):
         executor = PythonCodeExecution()
         self.assertEqual(0, len(executor.globals_ns))
-        self.assertEqual(0, len(executor.locals_ns))
 
-    def test_reset_context_clears_globals_and_locals(self):
+    def test_reset_context_clears_context(self):
         executor = PythonCodeExecution()
+        globals_len = len(executor.globals_ns)
         executor.execute("x = 1")
-        self.assertEqual(1, len(executor.globals_ns))
-        self.assertEqual(1, len(executor.locals_ns))
+        self.assertTrue(globals_len + 1, len(executor.globals_ns))
         executor.reset_context()
         self.assertEqual(0, len(executor.globals_ns))
-        self.assertEqual(0, len(executor.locals_ns))
 
     # ---------------------------------------------------------------------------
     # Successful execution tests
     # ---------------------------------------------------------------------------
-    def test_execute_places_output_in_locals_mapping_if_different_to_globals(self):
+    def test_execute_places_output_in_globals(self):
         code = "_local=100"
-        user_globals, user_locals = self._verify_serial_execution_successful(code)
-        self.assertEquals(100, user_locals['_local'])
-        self.assertTrue('_local' not in user_globals)
-        user_globals, user_locals = self._verify_async_execution_successful(code)
-        self.assertEquals(100, user_locals['_local'])
-        self.assertTrue('_local' not in user_globals)
+        user_globals = self._verify_serial_execution_successful(code)
+        self.assertEquals(100, user_globals['_local'])
+        user_globals = self._verify_async_execution_successful(code)
+        self.assertEquals(100, user_globals['_local'])
 
     def test_execute_async_calls_success_signal_on_completion(self):
         code = "x=1+2"
@@ -186,7 +182,7 @@ squared = sum*sum
             else:
                 self.fail("No callback was called!")
 
-        context = executor.locals_ns
+        context = executor.globals_ns
         self.assertEqual(20, context['sum'])
         self.assertEqual(20*20, context['squared'])
         self.assertEqual(1, context['x'])
@@ -208,13 +204,13 @@ squared = sum*sum
     def _verify_serial_execution_successful(self, code):
         executor = PythonCodeExecution()
         executor.execute(code)
-        return executor.globals_ns, executor.locals_ns
+        return executor.globals_ns
 
     def _verify_async_execution_successful(self, code):
         executor = PythonCodeExecution()
         task = executor.execute_async(code)
         task.join()
-        return executor.globals_ns, executor.locals_ns
+        return executor.globals_ns
 
     def _verify_failed_serial_execute(self, expected_exc_type, code):
         executor = PythonCodeExecution()
diff --git a/qt/python/mantidqt/widgets/src/_widgetscore.sip b/qt/python/mantidqt/widgets/src/_widgetscore.sip
index be14405d4f4..4f930b88b72 100644
--- a/qt/python/mantidqt/widgets/src/_widgetscore.sip
+++ b/qt/python/mantidqt/widgets/src/_widgetscore.sip
@@ -67,6 +67,14 @@ class ScriptEditor : QWidget {
 #include "MantidQtWidgets/Common/ScriptEditor.h"
 %End
 
+public:
+  enum AutoCompletionSource {
+      AcsNone,
+      AcsAll,
+      AcsDocument,
+      AcsAPIs
+  };
+
 public:
   ScriptEditor(const QString & language,
                QWidget *parent /TransferThis/ = 0) throw(std::invalid_argument);
@@ -85,6 +93,8 @@ public:
   QString selectedText() const;
   QString text() const;
 
+  void enableAutoCompletion(AutoCompletionSource source);
+  void disableAutoCompletion();
   void setCaretLineBackgroundColor (const QColor & col);
   void setCaretLineVisible(bool enable);
   void setIndentationsUseTabs(bool tabs);
@@ -95,6 +105,7 @@ public:
   void setSelection(int lineFrom, int indexFrom, int lineTo, int indexTo);
   void setTabWidth(int width);
   void setText(const QString &text);
+  void updateCompletionAPI(const QStringList & completions);
 
 public slots:
   void updateProgressMarker(int lineno, bool error);
diff --git a/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h b/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h
index 54f39ef8f18..48714389ecc 100644
--- a/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h
+++ b/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h
@@ -87,8 +87,10 @@ public:
   void setLexer(QsciLexer *) override;
   // Make the object resize to margin to fit the contents
   void setAutoMarginResize();
-  /// Enable the auto complete
-  void enableAutoCompletion();
+  /// Enable the auto complete. Default is for backwards compatability
+  /// with existing code
+  void
+  enableAutoCompletion(AutoCompletionSource source = QsciScintilla::AcsAPIs);
   /// Disable the auto complete
   void disableAutoCompletion();
 
diff --git a/qt/widgets/common/src/ScriptEditor.cpp b/qt/widgets/common/src/ScriptEditor.cpp
index 39d16e8317e..9a5ab89d7f7 100644
--- a/qt/widgets/common/src/ScriptEditor.cpp
+++ b/qt/widgets/common/src/ScriptEditor.cpp
@@ -177,10 +177,10 @@ void ScriptEditor::setAutoMarginResize() {
 /**
  * Enable the auto complete
  */
-void ScriptEditor::enableAutoCompletion() {
-  setAutoCompletionSource(QsciScintilla::AcsAPIs);
-  setCallTipsVisible(QsciScintilla::CallTipsNoAutoCompletionContext);
+void ScriptEditor::enableAutoCompletion(AutoCompletionSource source) {
+  setAutoCompletionSource(source);
   setAutoCompletionThreshold(2);
+  setCallTipsStyle(QsciScintilla::CallTipsNoAutoCompletionContext);
   setCallTipsVisible(0); // This actually makes all of them visible
 }
 
@@ -189,7 +189,6 @@ void ScriptEditor::enableAutoCompletion() {
  * */
 void ScriptEditor::disableAutoCompletion() {
   setAutoCompletionSource(QsciScintilla::AcsNone);
-  setCallTipsVisible(QsciScintilla::CallTipsNone);
   setAutoCompletionThreshold(-1);
   setCallTipsVisible(-1);
 }
-- 
GitLab