Repository: spyder-ide/spyder

Issue:
Improvements to URL awareness
* Make URLs work with `%20`.
* Remove the requirement of `mailto:`
* Make `{gh/bb/gl}-123` work.



Discussion:

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -20,13 +20,13 @@
 # Standard library imports
 from __future__ import division, print_function
 
+from unicodedata import category
 import logging
 import os.path as osp
 import re
 import sre_constants
 import sys
 import time
-from unicodedata import category
 
 # Third party imports
 from diff_match_patch import diff_match_patch
@@ -78,6 +78,7 @@
 from spyder.utils import syntaxhighlighters as sh
 from spyder.utils.qthelpers import (add_actions, create_action, file_uri,
                                     mimedata2url)
+from spyder.utils.vcs import get_git_remotes, remote_to_url
 
 
 try:
@@ -322,7 +323,8 @@ def __init__(self, parent=None):
         self._timer_mouse_moving.timeout.connect(lambda: self._handle_hover())
 
         # Goto uri
-        self._last_hover_uri = None
+        self._last_hover_pattern_key = None
+        self._last_hover_pattern_text = None
 
         # 79-col edge line
         self.edge_line = self.panels.register(EdgeLine(self),
@@ -546,19 +548,18 @@ def _handle_hover(self):
         ignore_chars = ['(', ')', '.']
 
         if self._should_display_hover(pos):
-            uri, cursor = self.get_uri_at(pos)
+            key, pattern_text, cursor = self.get_pattern_at(pos)
             text = self.get_word_at(pos)
-            if uri:
+            if pattern_text:
                 ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl'
-
-                if uri.startswith('file://'):
-                    hint_text = ctrl_text + ' + click to open file'
-                elif uri.startswith('mailto:'):
-                    hint_text = ctrl_text + ' + click to send email'
-                elif uri.startswith('http'):
-                    hint_text = ctrl_text + ' + click to open url'
+                if key in ['file']:
+                    hint_text = ctrl_text + ' + ' + _('click to open file')
+                elif key in ['mail']:
+                    hint_text = ctrl_text + ' + ' + _('click to send email')
+                elif key in ['url']:
+                    hint_text = ctrl_text + ' + ' + _('click to open url')
                 else:
-                    hint_text = ctrl_text + ' + click to open'
+                    hint_text = ctrl_text + ' + ' + _('click to open')
 
                 hint_text = '<span>&nbsp;{}&nbsp;</span>'.format(hint_text)
 
@@ -3326,9 +3327,12 @@ def run_pygments_highlighter(self):
         if isinstance(self.highlighter, sh.PygmentsSH):
             self.highlighter.make_charlist()
 
-    def get_uri_at(self, coordinates):
-        """Return uri and cursor for if uri found at coordinates."""
-        return self.get_pattern_cursor_at(sh.URI_PATTERNS, coordinates)
+    def get_pattern_at(self, coordinates):
+        """
+        Return key, text and cursor for pattern (if found at coordinates).
+        """
+        return self.get_pattern_cursor_at(self.highlighter.patterns,
+                                          coordinates)
 
     def get_pattern_cursor_at(self, pattern, coordinates):
         """
@@ -3336,36 +3340,45 @@ def get_pattern_cursor_at(self, pattern, coordinates):
 
         This returns the actual match and the cursor that selects the text.
         """
+        cursor, key, text = None, None, None
+        break_loop = False
+
         # Check if the pattern is in line
         line = self.get_line_at(coordinates)
         match = pattern.search(line)
-        uri = None
-        cursor = None
 
         while match:
-            start, end = match.span()
+            for key, value in list(match.groupdict().items()):
+                if value:
+                    start, end = match.span()
+
+                    # Get cursor selection if pattern found
+                    cursor = self.cursorForPosition(coordinates)
+                    cursor.movePosition(QTextCursor.StartOfBlock)
+                    line_start_position = cursor.position()
+
+                    cursor.setPosition(line_start_position + start,
+                                       cursor.MoveAnchor)
+                    start_rect = self.cursorRect(cursor)
+                    cursor.setPosition(line_start_position + end,
+                                       cursor.MoveAnchor)
+                    end_rect = self.cursorRect(cursor)
+                    bounding_rect = start_rect.united(end_rect)
+
+                    # Check coordinates are located within the selection rect
+                    if bounding_rect.contains(coordinates):
+                        text = line[start:end]
+                        cursor.setPosition(line_start_position + start,
+                                           cursor.KeepAnchor)
+                        break_loop = True
+                        break
 
-            # Get cursor selection if pattern found
-            cursor = self.cursorForPosition(coordinates)
-            cursor.movePosition(QTextCursor.StartOfBlock)
-            line_start_position = cursor.position()
-
-            cursor.setPosition(line_start_position + start, cursor.MoveAnchor)
-            start_rect = self.cursorRect(cursor)
-            cursor.setPosition(line_start_position + end, cursor.MoveAnchor)
-            end_rect = self.cursorRect(cursor)
-            bounding_rect = start_rect.united(end_rect)
-
-            # Check if coordinates are located within the selection rect
-            if bounding_rect.contains(coordinates):
-                uri = line[start:end]
-                cursor.setPosition(line_start_position + start,
-                                   cursor.KeepAnchor)
+            if break_loop:
                 break
-            else:
-                match = pattern.search(line, end)
 
-        return uri, cursor
+            match = pattern.search(line, end)
+
+        return key, text, cursor
 
     def _preprocess_file_uri(self, uri):
         """Format uri to conform to absolute or relative file paths."""
@@ -3400,12 +3413,15 @@ def _handle_goto_definition_event(self, pos):
 
     def _handle_goto_uri_event(self, pos):
         """Check if go to uri can be applied and apply highlight."""
-        uri, cursor = self.get_uri_at(pos)
-        if uri and cursor:
+        key, pattern_text, cursor = self.get_pattern_at(pos)
+        if key and pattern_text and cursor:
+            self._last_hover_pattern_key = key
+            self._last_hover_pattern_text = pattern_text
+
             color = self.ctrl_click_color
 
-            if uri.startswith('file://'):
-                fname = self._preprocess_file_uri(uri)
+            if key in ['file']:
+                fname = self._preprocess_file_uri(pattern_text)
                 if not osp.isfile(fname):
                     color = QColor(255, 80, 80)
 
@@ -3415,17 +3431,75 @@ def _handle_goto_uri_event(self, pos):
                 foreground_color=color,
                 underline_color=color,
                 underline_style=QTextCharFormat.SingleUnderline)
+
             if not self.__cursor_changed:
                 QApplication.setOverrideCursor(
                     QCursor(Qt.PointingHandCursor))
                 self.__cursor_changed = True
-            self._last_hover_uri = uri
-            self.sig_uri_found.emit(uri)
+
+            self.sig_uri_found.emit(pattern_text)
             return True
         else:
-            self._last_hover_uri = uri
+            self._last_hover_pattern_key = key
+            self._last_hover_pattern_text = pattern_text
             return False
 
+    def go_to_uri_from_cursor(self, uri):
+        """Go to url from cursor and defined hover patterns."""
+        key = self._last_hover_pattern_key
+        if key in ['file']:
+            fname = self._preprocess_file_uri(uri)
+
+            if osp.isfile(fname) and encoding.is_text_file(fname):
+                # Open in editor
+                self.go_to_definition.emit(fname, 0, 0)
+            else:
+                # Use external program
+                fname = file_uri(fname)
+                programs.start_file(fname)
+        elif key in ['mail', 'url']:
+            if '@' in uri and not uri.startswith('mailto:'):
+                full_uri = 'mailto:' + uri
+            quri = QUrl(full_uri)
+            QDesktopServices.openUrl(quri)
+        elif key in ['issue']:
+            # Issue URI
+            repo_url = uri.replace('#', '/issues/')
+            if uri.startswith(('gh-', 'bb-', 'gl-')):
+                number = uri[3:]
+                remotes = get_git_remotes(self.filename)
+                remote = remotes.get('upstream', remotes.get('origin'))
+                if remote:
+                    full_uri = remote_to_url(remote) + '/issues/' + number
+                else:
+                    full_uri = None
+            elif uri.startswith('gh:') or ':' not in uri:
+                # Github
+                repo_and_issue = repo_url
+                if uri.startswith('gh:'):
+                    repo_and_issue = repo_url[3:]
+                full_uri = 'https://github.com/' + repo_and_issue
+            elif uri.startswith('gl:'):
+                # Gitlab
+                full_uri = 'https://gitlab.com/' + repo_url[3:]
+            elif uri.startswith('bb:'):
+                # Bitbucket
+                full_uri = 'https://bitbucket.org/' + repo_url[3:]
+
+            if full_uri:
+                quri = QUrl(full_uri)
+                QDesktopServices.openUrl(quri)
+            else:
+                QMessageBox.information(
+                    self,
+                    _('Information'),
+                    _('This file is not part of a local repository or '
+                      'upstream/origin remotes are not defined!'),
+                    QMessageBox.Ok,
+                )
+        self.sig_go_to_uri.emit(uri)
+        return full_uri
+
     def line_range(self, position):
         """
         Get line range from position.
@@ -3511,7 +3585,6 @@ def pos_in_line(pos):
             return line_range[1] - (line_range[0] + len(strip))
         return 0
 
-
     def mouseMoveEvent(self, event):
         """Underline words when pressing <CONTROL>"""
         # Restart timer every time the mouse is moved
@@ -3583,45 +3656,9 @@ def mousePressEvent(self, event):
         if event.button() == Qt.LeftButton and ctrl:
             TextEditBaseWidget.mousePressEvent(self, event)
             cursor = self.cursorForPosition(pos)
-
-            if self._last_hover_uri:
-                uri = self._last_hover_uri
-                if uri.startswith('file://'):
-                    fname = self._preprocess_file_uri(uri)
-
-                    if osp.isfile(fname) and encoding.is_text_file(fname):
-                        # Open in editor
-                        self.go_to_definition.emit(fname, 0, 0)
-                    else:
-                        # Use external program
-                        fname = file_uri(fname)
-                        programs.start_file(fname)
-                elif uri.startswith(('http', 'mailto:')):
-                    quri = QUrl(uri)
-                    QDesktopServices.openUrl(quri)
-                else:
-                    # Issue URI
-                    service = 'https://github.com/'
-                    uri = uri.replace('#', '/issues/')
-
-                    if uri.startswith('gh:') or ':' not in uri:
-                        # Github
-                        if uri.startswith('gh:'):
-                            uri = uri[3:]
-                        service = 'https://github.com/'
-                    elif uri.startswith('gl:'):
-                        # Gitlab
-                        uri = uri[3:]
-                        service = 'https://gitlab.com/'
-                    elif uri.startswith('bb:'):
-                        # Bitbucket
-                        uri = uri[3:]
-                        service = 'https://bitbucket.org/'
-
-                    quri = QUrl(service + uri)
-                    QDesktopServices.openUrl(quri)
-
-                self.sig_go_to_uri.emit(uri)
+            uri = self._last_hover_pattern_text
+            if uri:
+                self.go_to_uri_from_cursor(uri)
             else:
                 self.go_to_definition_from_cursor(cursor)
         elif event.button() == Qt.LeftButton and alt:
@@ -3675,7 +3712,8 @@ def _restore_editor_cursor_and_selections(self):
             self.__cursor_changed = False
             QApplication.restoreOverrideCursor()
             self.clear_extra_selections('ctrl_click')
-            self._last_hover_uri = None
+            self._last_hover_pattern_key = None
+            self._last_hover_pattern_text = None
 
     #------ Drag and drop
     def dragEnterEvent(self, event):
diff --git a/spyder/utils/syntaxhighlighters.py b/spyder/utils/syntaxhighlighters.py
--- a/spyder/utils/syntaxhighlighters.py
+++ b/spyder/utils/syntaxhighlighters.py
@@ -47,10 +47,17 @@
 # =============================================================================
 # Constants
 # =============================================================================
-URL_PATTERN = r"https?://([\da-z\.-]+)\.([a-z\.]{2,6})([/\w\.-]*)[^ ^'^\"]+"
-FILE_PATTERN = r"file:///?([\S ]*)/"
-MAILTO_PATTERN = r"mailto:\s*([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})"
-ISSUE_PATTERN = r'((?:\w\w:)?[\w\-_]*/[\w\-_]*#\d+)'
+DEFAULT_PATTERNS = {
+    'file':
+        r'file:///?(?:[\S]*)',
+    'issue':
+        (r'(?:(?:(?:gh:)|(?:gl:)|(?:bb:))?[\w\-_]*/[\w\-_]*#\d+)|'
+         r'(?:(?:(?:gh-)|(?:gl-)|(?:bb-))\d+)'),
+    'mail':
+        r'(?:mailto:\s*)?([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})',
+    'url':
+        r"https?://([\da-z\.-]+)\.([a-z\.]{2,6})([/\w\.-]*)[^ ^'^\"]+",
+}
 COLOR_SCHEME_KEYS = {
                       "background":     _("Background:"),
                       "currentline":    _("Current line:"),
@@ -111,9 +118,28 @@ def any(name, alternates):
     return "(?P<%s>" % name + "|".join(alternates) + ")"
 
 
-URI_PATTERNS = re.compile(
-    any('uri', [URL_PATTERN, FILE_PATTERN, MAILTO_PATTERN, ISSUE_PATTERN])
-)
+def create_patterns(patterns, compile=False):
+    """
+    Create patterns from pattern dictionary.
+
+    The key correspond to the group name and the values a list of
+    possible pattern alternatives.
+    """
+    all_patterns = []
+    for key, value in patterns.items():
+        all_patterns.append(any(key, [value]))
+
+    regex = '|'.join(all_patterns)
+
+    if compile:
+        regex = re.compile(regex)
+
+    return regex
+
+
+DEFAULT_PATTERNS_TEXT = create_patterns(DEFAULT_PATTERNS, compile=False)
+DEFAULT_COMPILED_PATTERNS = re.compile(create_patterns(DEFAULT_PATTERNS,
+                                                       compile=True))
 
 
 #==============================================================================
@@ -155,6 +181,7 @@ def __init__(self, parent, font=None, color_scheme='Spyder'):
         self.cell_separators = None
         self.fold_detector = None
         self.editor = None
+        self.patterns = DEFAULT_COMPILED_PATTERNS
 
     def get_background_color(self):
         return QColor(self.background_color)
@@ -235,6 +262,20 @@ def _find_prev_non_blank_block(current_block):
             previous_block = previous_block.previous()
         return previous_block
 
+    def update_patterns(self, patterns):
+        """Update patterns to underline."""
+        all_patterns = DEFAULT_PATTERNS.copy()
+        additional_patterns = patterns.copy()
+
+        # Check that default keys are not overwritten
+        for key in DEFAULT_PATTERNS.keys():
+            if key in additional_patterns:
+                # TODO: print warning or check this at the plugin level?
+                additional_patterns.pop(key)
+        all_patterns.update(additional_patterns)
+
+        self.patterns = create_patterns(all_patterns, compile=True)
+
     def highlightBlock(self, text):
         """
         Highlights a block of text. Please do not override, this method.
@@ -263,24 +304,24 @@ def highlight_block(self, text):
         """
         raise NotImplementedError()
 
-    def highlight_uris(self, text, offset=0):
+    def highlight_patterns(self, text, offset=0):
         """Highlight URI and mailto: patterns."""
-        match = URI_PATTERNS.search(text, offset)
+        match = self.patterns.search(text, offset)
         while match:
-            start, end = match.span()
-            start = max([0, start+offset])
-            end = max([0, end+offset])
-            font = self.format(start)
-            font.setUnderlineStyle(True)
-            self.setFormat(start, end - start, font)
-            match = URI_PATTERNS.search(text, end)
+            for __, value in list(match.groupdict().items()):
+                if value:
+                    start, end = match.span()
+                    start = max([0, start + offset])
+                    end = max([0, end + offset])
+                    font = self.format(start)
+                    font.setUnderlineStyle(True)
+                    self.setFormat(start, end - start, font)
+            match = self.patterns.search(text, end)
 
     def highlight_spaces(self, text, offset=0):
         """
         Make blank space less apparent by setting the foreground alpha.
         This only has an effect when 'Show blank space' is turned on.
-        Derived classes could call this function at the end of
-        highlightBlock().
         """
         flags_text = self.document().defaultTextOption().flags()
         show_blanks =  flags_text & QTextOption.ShowTabsAndSpaces
@@ -305,12 +346,15 @@ def highlight_spaces(self, text, offset=0):
                 self.setFormat(start, end-start, color_foreground)
                 match = self.BLANKPROG.search(text, match.end())
 
-        self.highlight_uris(text, offset)
-
     def highlight_extras(self, text, offset=0):
-        """Perform additional global text highlight."""
+        """
+        Perform additional global text highlight.
+
+        Derived classes could call this function at the end of
+        highlight_block().
+        """
         self.highlight_spaces(text, offset=offset)
-        self.highlight_uris(text, offset=offset)
+        self.highlight_patterns(text, offset=offset)
 
     def rehighlight(self):
         QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
diff --git a/spyder/utils/vcs.py b/spyder/utils/vcs.py
--- a/spyder/utils/vcs.py
+++ b/spyder/utils/vcs.py
@@ -208,3 +208,37 @@ def get_git_refs(repopath):
             pass
 
     return branches + tags, branch, files_modifed
+
+
+def get_git_remotes(fpath):
+    """Return git remotes for repo on fpath."""
+    remote_data = {}
+    data, __ = programs.run_program(
+        'git',
+        ['remote', '-v'],
+        cwd=osp.dirname(fpath),
+    ).communicate()
+
+    if PY3:
+        data = data.decode(sys.getdefaultencoding())
+
+    lines = [line.strip() for line in data.split('\n') if line]
+    for line in lines:
+        if line:
+            remote, value = line.split('\t')
+            remote_data[remote] = value.split(' ')[0]
+
+    return remote_data
+
+
+def remote_to_url(remote):
+    """Convert a git remote to a url."""
+    url = ''
+    if remote.startswith('git@'):
+        url = remote.replace('git@', '')
+        url = url.replace(':', '/')
+        url = 'https://' + url.replace('.git', '')
+    else:
+        url = remote.replace('.git', '')
+
+    return url


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_goto.py b/spyder/plugins/editor/widgets/tests/test_goto.py
--- a/spyder/plugins/editor/widgets/tests/test_goto.py
+++ b/spyder/plugins/editor/widgets/tests/test_goto.py
@@ -20,8 +20,9 @@
 # Constants
 TEST_FOLDER = os.path.abspath(os.path.dirname(__file__))
 _, TEMPFILE_PATH = tempfile.mkstemp()
-TEST_FILE_ABS = [os.path.join(TEST_FOLDER, f) for f in
-                 os.listdir(TEST_FOLDER) if f.endswith('.py')][0]
+TEST_FILES = [os.path.join(TEST_FOLDER, f) for f in
+              os.listdir(TEST_FOLDER) if f.endswith('.py')]
+TEST_FILE_ABS = TEST_FILES[0].replace(' ', '%20')
 TEST_FILE_REL = [f for f in os.listdir(TEST_FOLDER) if f.endswith('.py')][0]
 
 
@@ -37,7 +38,8 @@
             ('"file://./{}/"\n'.format(TEST_FILE_REL), TEST_FILE_REL,
              os.path.join(TEST_FOLDER, TEST_FILE_REL)),
             # Files that do not exist
-            ('"file:///not there/"', 'file:///not there/', '/not there/'),
+            ('"file:///not%20there/"', 'file:///not%20there/',
+             '/not%20there/'),
             ('"file:///not_there/"', 'file:///not_there/', '/not_there/'),
             # Urls
             ('" https://google.com"\n', 'https://google.com', None),
@@ -45,6 +47,8 @@
             # Mail to
             ('" mailto:some@email.com"\n', 'mailto:some@email.com', None),
             ('# mailto:some@email.com\n', 'mailto:some@email.com', None),
+            ('some@email.com\n', 'some@email.com', None),
+            ('# some@email.com\n', 'some@email.com', None),
             # Issues
             ('# gl:gitlab-org/gitlab-ce#62529\n',
              'gl:gitlab-org/gitlab-ce#62529', None),
@@ -54,6 +58,8 @@
              'gh:spyder-ide/spyder#123', None),
             ('# gh:spyder-ide/spyder#123\n',
              'gh:spyder-ide/spyder#123', None),
+            ('# gh-123\n',
+             'gh-123', None),
         ]
     )
 def test_goto_uri(qtbot, editorbot, params):
diff --git a/spyder/utils/tests/test_vcs.py b/spyder/utils/tests/test_vcs.py
--- a/spyder/utils/tests/test_vcs.py
+++ b/spyder/utils/tests/test_vcs.py
@@ -14,12 +14,16 @@
 import sys
 
 # Test library imports
+from spyder.utils import programs
 import pytest
 
 # Local imports
 from spyder.utils.vcs import (ActionToolNotFound, get_git_refs,
-                              get_git_revision, get_vcs_root, run_vcs_tool)
-from spyder.utils import programs
+                              get_git_remotes, get_git_revision, get_vcs_root,
+                              remote_to_url, run_vcs_tool)
+
+
+HERE = os.path.abspath(os.path.dirname(__file__))
 
 
 @pytest.mark.skipif(os.environ.get('CI', None) is None,
@@ -70,5 +74,26 @@ def test_get_git_refs():
         assert any(['master' in b for b in branch_tags])
 
 
+def test_get_git_remotes():
+    remotes = get_git_remotes(HERE)
+    assert 'origin' in remotes
+
+
+@pytest.mark.parametrize(
+    'input_text, expected_output',
+    [
+        ('https://github.com/neophnx/spyder.git',
+         'https://github.com/neophnx/spyder'),
+        ('http://github.com/neophnx/spyder.git',
+         'http://github.com/neophnx/spyder'),
+        ('git@github.com:goanpeca/spyder.git',
+         'https://github.com/goanpeca/spyder'),
+    ]
+)
+def test_remote_to_url(input_text, expected_output):
+    output = remote_to_url(input_text)
+    assert expected_output == output
+
+
 if __name__ == "__main__":
     pytest.main()