[gedit] The outputpanel's linking functionality was refactored



commit a169d313af2ba0549c5d1a865766d7768a5dea83
Author: Per Arneng <per arneng anyplanet com>
Date:   Fri Jan 1 20:08:17 2010 +0100

    The outputpanel's linking functionality was refactored
    
    The outputpanel can parse the output and if it finds
    certain file and line number patterns it can make links
    of them that the user can click. This functionality is
    now updated with the following features:
    * Easier to add parsers for different tools
    * Python error links now supported
    * Easier to add new ways of finding files from the links
    * Two new ways of file lookup's added

 plugins/externaltools/tools/Makefile.am         |    4 +-
 plugins/externaltools/tools/filelookup.py       |  146 +++++++++++++++++++++
 plugins/externaltools/tools/linkparsing.py      |  133 +++++++++++++++++++
 plugins/externaltools/tools/linkparsing_test.py |  106 +++++++++++++++
 plugins/externaltools/tools/outputpanel.py      |  156 +++++++++++-----------
 5 files changed, 466 insertions(+), 79 deletions(-)
---
diff --git a/plugins/externaltools/tools/Makefile.am b/plugins/externaltools/tools/Makefile.am
index 7ca5498..5edcab5 100644
--- a/plugins/externaltools/tools/Makefile.am
+++ b/plugins/externaltools/tools/Makefile.am
@@ -7,7 +7,9 @@ plugin_PYTHON =		\
 	library.py	\
 	functions.py	\
 	manager.py	\
-	outputpanel.py
+	outputpanel.py	\
+	filelookup.py	\
+	linkparsing.py
 
 uidir = $(GEDIT_PLUGINS_DATA_DIR)/externaltools/ui
 ui_DATA = tools.ui \
diff --git a/plugins/externaltools/tools/filelookup.py b/plugins/externaltools/tools/filelookup.py
new file mode 100644
index 0000000..d3b24be
--- /dev/null
+++ b/plugins/externaltools/tools/filelookup.py
@@ -0,0 +1,146 @@
+# -*- coding: utf-8 -*-
+#
+#    Copyright (C) 2009-2010  Per Arneng <per arneng anyplanet com>
+#
+#    This program is free software; you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation; either version 2 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program; if not, write to the Free Software
+#    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+
+import os
+import gio
+import gedit
+
+class FileLookup:
+    """
+    This class is responsible for looking up files given a part or the whole
+    path of a real file. The lookup is delegated to providers wich use different
+    methods of trying to find the real file.
+    """
+
+    def __init__(self):
+        self.providers = []
+        self.providers.append(AbsoluteFileLookupProvider())
+        self.providers.append(CwdFileLookupProvider())
+        self.providers.append(OpenDocumentRelPathFileLookupProvider())
+        self.providers.append(OpenDocumentFileLookupProvider())
+
+    def lookup(self, path):
+        """
+        Tries to find a file specified by the path parameter. It delegates to
+        different lookup providers and the first match is returned. If no file
+        was found then None is returned.
+
+        path -- the path to find
+        """
+        found_file = None
+        for provider in self.providers:
+            found_file = provider.lookup(path)
+            if found_file is not None:
+                break
+
+        return found_file
+
+
+class FileLookupProvider:
+    """
+    The base class of all file lookup providers.
+    """
+
+    def lookup(self, path):
+        """
+        This method must be implemented by subclasses. Implementors will be
+        given a path and will try to find a matching file. If no file is found
+        then None is returned.
+        """
+        raise NotImplementedError("need to implement a lookup method")
+
+
+class AbsoluteFileLookupProvider(FileLookupProvider):
+    """
+    This file tries to see if the path given is an absolute path and that the
+    path references a file.
+    """
+
+    def lookup(self, path):
+        if os.path.isabs(path) and os.path.isfile(path):
+            return gio.File(path)
+        else:
+            return None
+
+
+class CwdFileLookupProvider(FileLookupProvider):
+    """
+    This lookup provider tries to find a file specified by the path relative to
+    the current working directory.
+    """
+
+    def lookup(self, path):
+        try:
+            cwd = os.getcwd()
+        except OSError:
+            cwd = os.getenv('HOME')
+
+        real_path = os.path.join(cwd, path)
+
+        if os.path.isfile(real_path):
+            return gio.File(real_path)
+        else:
+            return None
+
+
+class OpenDocumentRelPathFileLookupProvider(FileLookupProvider):
+    """
+    Tries to see if the path is relative to any directories where the
+    currently open documents reside in. Example: If you have a document opened
+    '/tmp/Makefile' and a lookup is made for 'src/test2.c' then this class
+    will try to find '/tmp/src/test2.c'.
+    """
+
+    def lookup(self, path):
+        if path.startswith('/'):
+            return None
+
+        for doc in gedit.app_get_default().get_documents():
+            if doc.is_local():
+                uri = doc.get_uri()
+                if uri:
+                    rel_path = gio.File(doc.get_uri()).get_parent().get_path()
+                    joined_path = os.path.join(rel_path, path)
+                    if os.path.isfile(joined_path):
+                        return gio.File(joined_path)
+
+        return None
+
+
+class OpenDocumentFileLookupProvider(FileLookupProvider):
+    """
+    Makes a guess that the if the path that was looked for matches the end
+    of the path of a currently open document then that document is the one
+    that is looked for. Example: If a document is opened called '/tmp/t.c'
+    and a lookup is made for 't.c' or 'tmp/t.c' then both will match since
+    the open document ends with the path that is searched for.
+    """
+
+    def lookup(self, path):
+        if path.startswith('/'):
+            return None
+
+        for doc in gedit.app_get_default().get_documents():
+            if doc.is_local():
+                uri = doc.get_uri()
+                if uri:
+                    if uri.endswith(path):
+                        return gio.File(doc.get_uri())
+        return None
+
+# ex:ts=4:et:
diff --git a/plugins/externaltools/tools/linkparsing.py b/plugins/externaltools/tools/linkparsing.py
new file mode 100644
index 0000000..b0f60df
--- /dev/null
+++ b/plugins/externaltools/tools/linkparsing.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+#
+#    Copyright (C) 2009-2010  Per Arneng <per arneng anyplanet com>
+#
+#    This program is free software; you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation; either version 2 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program; if not, write to the Free Software
+#    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+
+import re
+
+class Link:
+    """
+    This class represents a file link from within a string given by the
+    output of some software tool.
+    """
+
+    def __init__(self, path, line_nr, start, end):
+        """
+        path -- the path of the file (that could be extracted)
+        line_nr -- the line nr of the specified file
+        start -- the index within the string that the link starts at
+        end -- the index within the string where the link ends at
+        """
+        self.path    = path
+        self.line_nr = int(line_nr)
+        self.start   = start
+        self.end     = end
+
+    def __repr__(self):
+        return "%s[%s](%s:%s)" % (self.path, self.line_nr, 
+                                  self.start, self.end)
+
+class LinkParser:
+    """
+    Parses a text using different parsing providers with the goal of finding one
+    or more file links within the text. A typicak example could be the output
+    from a compiler that specifies an error in a specific file. The path of the
+    file, the line nr and some more info is then returned so that it can be used
+    to be able to navigate from the error output in to the specific file.
+
+    The actual work of parsing the text is done by instances of classes that
+    inherits from LinkParserProvider. To add a new parser just create a class
+    that inherits from LinkParserProvider and override the parse method. Then
+    you need to register the class in the _provider list of this class wich is
+    done in the class constructor.
+    """
+
+    def __init__(self):
+        self._providers = []
+        self._providers.append(GccLinkParserProvider())
+        self._providers.append(PythonLinkParserProvider())
+
+    def parse(self, text):
+        """
+        Parses the given text and returns a list of links that are parsed from
+        the text. This method delegates to parser providers that can parse
+        output from different kinds of formats. If no links are found then an
+        empty list is returned.
+
+        text -- the text to scan for file links. 'text' can not be None.
+        """
+        if text is None:
+            raise ValueError("text can not be None")
+
+        links = []
+
+        for provider in self._providers:
+            links.extend(provider.parse(text))
+
+        return links
+
+class LinkParserProvider:
+    """The "abstract" base class for link parses"""
+
+    def parse(self, text):
+        """
+        This method should be implemented by subclasses. It takes a text as
+        argument (never None) and then returns a list of Link objects. If no
+        links are found then an empty list is expected. The Link class is
+        defined in this module. If you do not override this method then a
+        NotImplementedError will be thrown. 
+
+        text -- the text to parse. This argument is never None.
+        """
+        raise NotImplementedError("need to implement a parse method")
+
+class GccLinkParserProvider(LinkParserProvider):
+
+    def __init__(self):
+        self.fm = re.compile("^(.*)\:(\d+)\:", re.MULTILINE)
+
+    def parse(self, text):
+        links = []
+        for m in re.finditer(self.fm, text):
+            path = m.group(1)
+            line_nr = m.group(2)
+            start = m.start(1)
+            end = m.end(2)
+            link = Link(path, line_nr, start, end)
+            links.append(link)
+
+        return links
+
+class PythonLinkParserProvider(LinkParserProvider):
+
+    def __init__(self):
+        # example:
+        #  File "test.py", line 10, in <module>
+        self.fm = re.compile("^  File \"([^\"]+)\", line (\d+),", re.MULTILINE)
+
+    def parse(self, text):
+        links = []
+        for m in re.finditer(self.fm, text):
+            path = m.group(1)
+            line_nr = m.group(2)
+            start = m.start(1) - 1
+            end = m.end(2)
+            link = Link(path, line_nr, start, end)
+            links.append(link)
+
+        return links
+
+# ex:ts=4:et:
diff --git a/plugins/externaltools/tools/linkparsing_test.py b/plugins/externaltools/tools/linkparsing_test.py
new file mode 100644
index 0000000..734229b
--- /dev/null
+++ b/plugins/externaltools/tools/linkparsing_test.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+#
+#    Copyright (C) 2009-2010  Per Arneng <per arneng anyplanet com>
+#
+#    This program is free software; you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation; either version 2 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program; if not, write to the Free Software
+#    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+
+import unittest
+from linkparsing import LinkParser
+from linkparsing import GccLinkParserProvider
+from linkparsing import PythonLinkParserProvider
+
+class LinkParserTest(unittest.TestCase):
+
+    def setUp(self):
+        self.p = LinkParser()
+
+    def test_parse_gcc_simple_test_with_real_output(self):
+        gcc_output = """
+test.c: In function 'f':
+test.c:5: warning: passing argument 1 of 'f' makes integer from pointer without a cast
+test.c:3: note: expected 'int' but argument is of type 'char *'
+test.c: In function 'main':
+test.c:11: warning: initialization makes pointer from integer without a cast
+test.c:12: warning: initialization makes integer from pointer without a cast
+test.c:13: error: too few arguments to function 'f'
+test.c:14: error: expected ';' before 'return'
+"""
+        links = self.p.parse(gcc_output)
+        self.assertEquals(len(links), 6, 'incorrect nr of links')
+        lnk = links[2]
+        self.assertEquals(lnk.path, 'test.c', 'incorrect path')
+        self.assertEquals(lnk.line_nr, 11, 'incorrect line nr')
+        self.assertEquals(gcc_output[lnk.start:lnk.end], 'test.c:11',
+                            'the link positions are incorrect')
+
+    def test_parse_gcc_one_line(self):
+        links = self.p.parse("/tmp/myfile.c:1212: error: ...")
+        self.assertEquals(len(links), 1, 'incorrect nr of links')
+        lnk = links[0]
+        self.assertEquals(lnk.path, '/tmp/myfile.c', 'incorrect path')
+        self.assertEquals(lnk.line_nr, 1212, 'incorrect line nr')
+        self.assertEquals(lnk.start, 0, 'incorrect start point')
+        self.assertEquals(lnk.end, 18, 'incorrect end point')
+
+    def test_parse_gcc_empty_string(self):
+        links = self.p.parse("")
+        self.assertEquals(len(links), 0, 'incorrect nr of links')
+
+    def test_parse_gcc_no_files_in_text(self):
+        links = self.p.parse("no file links in this string")
+        self.assertEquals(len(links), 0, 'incorrect nr of links')
+
+    def test_parse_gcc_none_as_argument(self):
+        self.assertRaises(ValueError, self.p.parse, None)
+
+    def test_parse_python_simple_test_with_real_output(self):
+        python_output = """
+Traceback (most recent call last):
+  File "test.py", line 10, in <module>
+    err()
+  File "test.py", line 7, in err
+    real_err()
+  File "test.py", line 4, in real_err
+    int('xxx')
+ValueError: invalid literal for int() with base 10: 'xxx'
+"""
+        links = self.p.parse(python_output)
+        self.assertEquals(len(links), 3, 'incorrect nr of links')
+        lnk = links[2]
+        self.assertEquals(lnk.path, 'test.py', 'incorrect path')
+        self.assertEquals(lnk.line_nr, 4, 'incorrect line nr')
+        link_string = python_output[lnk.start:lnk.end]
+        self.assertEquals(link_string, '"test.py", line 4',
+                            'the link positions are incorrect')
+        lnk = links[1]
+        self.assertEquals(lnk.path, 'test.py', 'incorrect path')
+        self.assertEquals(lnk.line_nr, 7, 'incorrect line nr')
+        link_string = python_output[lnk.start:lnk.end]
+        self.assertEquals(link_string, '"test.py", line 7',
+                            'the link positions are incorrect')
+
+    def test_parse_python_one_line(self):
+        links = self.p.parse("  File \"test.py\", line 10, in <module>")
+        self.assertEquals(len(links), 1, 'incorrect nr of links')
+        lnk = links[0]
+        self.assertEquals(lnk.path, 'test.py', 'incorrect path')
+        self.assertEquals(lnk.line_nr, 10, 'incorrect line nr')
+        self.assertEquals(lnk.start, 7, 'incorrect start point')
+        self.assertEquals(lnk.end, 25, 'incorrect end point')
+        
+if __name__ == '__main__':
+    unittest.main()
+
+# ex:ts=4:et:
diff --git a/plugins/externaltools/tools/outputpanel.py b/plugins/externaltools/tools/outputpanel.py
index 04f056d..a30aad7 100644
--- a/plugins/externaltools/tools/outputpanel.py
+++ b/plugins/externaltools/tools/outputpanel.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 #    Gedit External Tools plugin
 #    Copyright (C) 2005-2006  Steve Frécinaux <steve istique net>
+#    Copyright (C) 2010  Per Arneng <per arneng anyplanet com>
 #
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
@@ -27,6 +28,8 @@ from capture import *
 from gtk import gdk
 import re
 import gio
+import linkparsing
+import filelookup
 
 class UniqueById:
     __shared_state = WeakKeyDictionary()
@@ -63,33 +66,35 @@ class OutputPanel(UniqueById):
         self['view'].modify_font(pango.FontDescription('Monospace'))
 
         buffer = self['view'].get_buffer()
-        
-        self.normal_tag = buffer.create_tag("normal")
-        
-        self.error_tag = buffer.create_tag("error")
-        self.error_tag.set_property("foreground", "red")
-        
+
+        self.normal_tag = buffer.create_tag('normal')
+
+        self.error_tag = buffer.create_tag('error')
+        self.error_tag.set_property('foreground', 'red')
+
         self.italic_tag = buffer.create_tag('italic')
         self.italic_tag.set_property('style', pango.STYLE_OBLIQUE)
-        
+
         self.bold_tag = buffer.create_tag('bold')
         self.bold_tag.set_property('weight', pango.WEIGHT_BOLD)
-        
+
+        self.invalid_link_tag = buffer.create_tag('invalid_link')
+
         self.link_tag = buffer.create_tag('link')
-        self.link_tag.set_property('underline', pango.UNDERLINE_LOW)
-        
-        self.line_tag = buffer.create_tag('line')
-        
+        self.link_tag.set_property('underline', pango.UNDERLINE_SINGLE)
+
         self.link_cursor = gdk.Cursor(gdk.HAND2)
         self.normal_cursor = gdk.Cursor(gdk.XTERM)
-        
-        self.link_regex = re.compile('((\\./|\\.\\./|/)[^\s:]+|[^\s:]+\\.[^\s:]+)(:([0-9]+))?')
 
         self.process = None
 
+        self.links = []
+
+        self.link_parser = linkparsing.LinkParser()
+        self.file_lookup = filelookup.FileLookup()
+
     def set_process(self, process):
         self.process = process
-        self.cwd = process.cwd
 
     def __getitem__(self, key):
         # Convenience function to get an object from its name
@@ -108,14 +113,15 @@ class OutputPanel(UniqueById):
 
     def clear(self):
         self['view'].get_buffer().set_text("")
-    
+        self.links = []
+
     def visible(self):
         panel = self.window.get_bottom_panel()
         return panel.props.visible and panel.item_is_active(self.panel)
 
     def write(self, text, tag = None):
         buffer = self['view'].get_buffer()
-        
+
         end_iter = buffer.get_end_iter()
         insert = buffer.create_mark(None, end_iter, True)
 
@@ -123,27 +129,29 @@ class OutputPanel(UniqueById):
             buffer.insert(end_iter, text)
         else:
             buffer.insert_with_tags(end_iter, text, tag)
-        
-        for m in self.link_regex.finditer(text):
-            start = buffer.get_iter_at_mark(insert)
-            start.forward_chars(m.start(0))
-            end = start.copy()
-            end.forward_chars(m.end(0))
-            
-            filename = m.group(1)
+
+        # find all links and apply the appropriate tag for them
+        links = self.link_parser.parse(text)
+        for lnk in links:
             
-            if (os.path.isabs(filename) and os.path.isfile(filename)) or \
-               (os.path.isfile(os.path.join(self.cwd, filename))):
-                buffer.apply_tag(self.link_tag, start, end)
-                
-                if m.group(4):
-                    start = buffer.get_iter_at_mark(insert)
-                    start.forward_chars(m.start(4))
-                    end = start.copy()
-                    end.forward_chars(m.end(4))
+            insert_iter = buffer.get_iter_at_mark(insert)
+            lnk.start = insert_iter.get_offset() + lnk.start
+            lnk.end = insert_iter.get_offset() + lnk.end
             
-                    buffer.apply_tag(self.line_tag, start, end)
-        
+            start_iter = buffer.get_iter_at_offset(lnk.start)
+            end_iter = buffer.get_iter_at_offset(lnk.end)
+
+            tag = None
+
+            # if the link points to an existing file then it is a valid link
+            if self.file_lookup.lookup(lnk.path) is not None:
+                self.links.append(lnk)
+                tag = self.link_tag
+            else:
+                tag = self.invalid_link_tag
+
+            buffer.apply_tag(tag, start_iter, end_iter)
+
         buffer.delete_mark(insert)
         gobject.idle_add(self.scroll_to_end)
 
@@ -151,21 +159,18 @@ class OutputPanel(UniqueById):
         panel = self.window.get_bottom_panel()
         panel.show()
         panel.activate_item(self.panel)
-    
-    def update_cursor_style(self, view, x, y):
-        x, y = view.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, int(x), int(y))
-        piter = view.get_iter_at_location(x, y)
-        
-        if piter.has_tag(self.link_tag):
+
+    def update_cursor_style(self, view, x, y):       
+        if self.get_link_at_location(view, x, y) is not None:
             cursor = self.link_cursor
         else:
             cursor = self.normal_cursor
 
         view.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(cursor)
-    
+
     def on_view_motion_notify_event(self, view, event):
         if event.window == view.get_window(gtk.TEXT_WINDOW_TEXT):
-            self.update_cursor_style(view, event.x, event.y)
+            self.update_cursor_style(view, int(event.x), int(event.y))
 
         return False
 
@@ -175,50 +180,45 @@ class OutputPanel(UniqueById):
             self.update_cursor_style(view, x, y)
 
         return False
-    
+
     def idle_grab_focus(self):
         self.window.get_active_view().grab_focus()
         return False
-    
+
+    def get_link_at_location(self, view, x, y):
+        """
+        Get the link under a specified x,y coordinate. If no link exists then
+        None is returned.
+        """
+
+        # get the offset within the buffer from the x,y coordinates
+        buff_x, buff_y = view.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, 
+                                                        x, y)
+        iter_at_xy = view.get_iter_at_location(buff_x, buff_y)
+        offset = iter_at_xy.get_offset()
+
+        # find the first link that contains the offset
+        for lnk in self.links:
+            if offset >= lnk.start and offset <= lnk.end:
+                return lnk
+
+        # no link was found at x,y
+        return None
+
     def on_view_button_press_event(self, view, event):
         if event.button != 1 or event.type != gdk.BUTTON_PRESS or \
            event.window != view.get_window(gtk.TEXT_WINDOW_TEXT):
             return False
-        
-        x, y = view.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, int(event.x), int(event.y))
-        
-        start = view.get_iter_at_location(x, y)
 
-        if not start.has_tag(self.link_tag):
+        link = self.get_link_at_location(view, int(event.x), int(event.y))
+        if link is None:
             return False
-        
-        end = start.copy()
-        start.backward_to_tag_toggle(self.link_tag)
-        line = start.copy()
-        
-        end.forward_to_tag_toggle(self.link_tag)
-        line.forward_to_tag_toggle(self.line_tag)
-
-        if line.compare(end) < 0:
-            tot = line.copy()
-            tot.backward_char()
-            
-            text = start.get_text(tot)
-            toline = int(line.get_text(end))
-        else:
-            text = start.get_text(end)
-            toline = 0
-
-        gfile = None
-        
-        if os.path.isabs(text) and os.path.isfile(text):
-            gfile = gio.File(text)
-        elif os.path.isfile(os.path.join(self.cwd, text)):
-            gfile = gio.File(os.path.join(self.cwd, text))
-            
+
+        gfile = self.file_lookup.lookup(link.path)
+
         if gfile:
-            gedit.commands.load_uri(self.window, gfile.get_uri(), None, toline)
-            
+            gedit.commands.load_uri(self.window, gfile.get_uri(), None, 
+                                    link.line_nr)
             gobject.idle_add(self.idle_grab_focus)
 
 # ex:ts=4:et:



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]