diff --git a/src/hilite.cc b/src/hilite.cc
new file mode 100644
index 00000000..4bdb59dc
--- /dev/null
+++ b/src/hilite.cc
@@ -0,0 +1,174 @@
+/*
+ * Copyright © 2019 Egmont Koblinger
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ */
+
+#include "debug.h"
+#include "hilite.hh"
+#include "vtedefines.hh"
+#include "vteinternal.hh"
+
+using namespace vte::base;
+
+Hilite::Hilite()
+{
+        m_matches = g_array_new (false, false, sizeof (struct HiliteMatch));
+}
+
+Hilite::~Hilite()
+{
+        g_array_free (m_matches, true);
+}
+
+void
+Hilite::clear()
+{
+        g_array_set_size (m_matches, 0);
+}
+
+/* FIXME Gotta find a regex instead */
+void
+Hilite::find_word(const char *haystack, const char *needle, GArray *map,
+                  uint32_t foremask, uint32_t fore,
+                  uint32_t backmask, uint32_t back,
+                  uint32_t decomask, uint32_t deco,
+                  uint32_t attrmask, uint32_t attr)
+{
+        int off = 0;
+
+        const char *p;
+
+        while ((p = strcasestr(haystack + off, needle)) != NULL) {
+                HiliteMatch match;
+
+                match.start = g_array_index (map, vte::grid::coords, p - haystack);
+                match.end = g_array_index (map, vte::grid::coords, p - haystack + strlen(needle));
+                match.fore = (fore & foremask);
+                match.foremask = foremask;
+                match.back = (back & backmask);
+                match.backmask = backmask;
+                match.deco = (deco & decomask);
+                match.decomask = decomask;
+                match.attr = (attr & attrmask);
+                match.attrmask = attrmask;
+
+                g_array_append_val (m_matches, match);
+
+                off = p - haystack + 1;
+        }
+}
+
+void
+Hilite::paragraph(RingView *ringview, vte::grid::row_t start, vte::grid::row_t end)
+{
+        vte::grid::row_t row;
+        vte::grid::column_t i;
+        const VteRowData *row_data;
+        const VteCell *cell;
+
+        GString *string = g_string_new("");
+        GArray *map = g_array_new(false, false, sizeof (vte::grid::coords));  // map from extracted byte offset to terminal coordinates
+
+        for (row = start; row < end; row++) {
+                row_data = ringview->get_row(row);
+
+                for (i = 0; i < row_data->len; i++) {
+                        cell = _vte_row_data_get (row_data, i);
+                        if (cell->attr.fragment())
+                                continue;
+
+                        gsize prevlen = string->len;
+                        _vte_unistr_append_to_string (cell->c ? cell->c : ' ', string);
+
+                        vte::grid::coords pos {row, i};
+                        for (; prevlen < string->len; prevlen++) {
+                                g_array_append_val(map, pos);
+                        }
+                }
+        }
+
+        if (G_UNLIKELY (string->len == 0))
+                return;
+
+        /* Guard */
+        vte::grid::coords pos {row - 1, i};  /* {row, 0} would include empty area at the end of the line */
+        g_array_append_val(map, pos);
+
+        /* some examples to play with */
+
+        find_word(string->str, "error",   map, 0xFFFFFFFF, 11 /* bright yellow fg */, 0xFFFFFFFF,  9 /* dark red bg */, 0, 0, 0, 0);
+        find_word(string->str, "fail",    map, 0xFFFFFFFF, 11 /* bright yellow fg */, 0xFFFFFFFF,  9 /* dark red bg */, 0, 0, 0, 0);
+        find_word(string->str, "warning", map, 0, 0, 0xFFFFFFFF, 13 /* bright magenta bg */, 0, 0, 0, 0);
+
+        find_word(string->str, "bold",      map, 0, 0, 0, 0, 0, 0, VTE_ATTR_BOLD_MASK, VTE_ATTR_BOLD);
+        find_word(string->str, "italic",    map, 0, 0, 0, 0, 0, 0, VTE_ATTR_ITALIC_MASK, VTE_ATTR_ITALIC);
+        find_word(string->str, "under",     map, 0, 0, 0, 0, 0, 0, VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(1));
+        find_word(string->str, "double",    map, 0, 0, 0, 0, 0, 0, VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(2));
+        find_word(string->str, "curl",      map, 0, 0, 0, 0, 0, 0, VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(3));
+        find_word(string->str, "strike",    map, 0, 0, 0, 0, 0, 0, VTE_ATTR_STRIKETHROUGH_MASK, VTE_ATTR_STRIKETHROUGH);
+        find_word(string->str, "over",      map, 0, 0, 0, 0, 0, 0, VTE_ATTR_OVERLINE_MASK, VTE_ATTR_OVERLINE);
+        find_word(string->str, "blink",     map, 0, 0, 0, 0, 0, 0, VTE_ATTR_BLINK_MASK, VTE_ATTR_BLINK);
+
+        // these two don't work
+        find_word(string->str, "reverse",   map, 0, 0, 0, 0, 0, 0, VTE_ATTR_REVERSE_MASK, VTE_ATTR_REVERSE);
+        find_word(string->str, "invisible", map, 0, 0, 0, 0, 0, 0, VTE_ATTR_INVISIBLE_MASK, VTE_ATTR_INVISIBLE);
+
+        // strcasecmp() can't do caseless utf-8, of course
+        find_word(string->str, "ö",       map, 0, 0, 0xFFFFFFFF, 11 /* bright yellow bg */, 0, 0, 0, 0);
+        find_word(string->str, "Ö",       map, 0, 0, 0xFFFFFFFF, 11 /* bright yellow bg */, 0, 0, 0, 0);
+        find_word(string->str, "ü",       map, 0, 0, 0xFFFFFFFF, 11 /* bright yellow bg */, 0, 0, 0, 0);
+        find_word(string->str, "Ü",       map, 0, 0, 0xFFFFFFFF, 11 /* bright yellow bg */, 0, 0, 0, 0);
+
+        // test CJK: U+4000
+        find_word(string->str, "䀀",      map, 0, 0, 0xFFFFFFFF, 11 /* bright yellow bg */, 0, 0, 0, 0);
+
+        // some (likely) typos
+        find_word(string->str, "deubg",   map, 0, 0, 0, 0, 0xFFFFFFFF,  1 /* dark red undercurl */,  VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(3));
+        find_word(string->str, "occured", map, 0, 0, 0, 0, 0xFFFFFFFF,  1 /* dark red undercurl */,  VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(3));
+        find_word(string->str, "recieve", map, 0, 0, 0, 0, 0xFFFFFFFF,  1 /* dark red undercurl */,  VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(3));
+        find_word(string->str, "loose",   map, 0, 0, 0, 0, 0xFFFFFFFF,  4 /* dark blue undercurl */, VTE_ATTR_UNDERLINE_MASK, VTE_ATTR_UNDERLINE(3));
+
+        /* end of examples */
+
+        g_array_free (map, true);
+        g_string_free (string, true);
+}
+
+void
+Hilite::highlight(vte::grid::row_t row,
+                  vte::grid::column_t col,
+                  uint32_t *fore,
+                  uint32_t *back,
+                  uint32_t *deco,
+                  uint32_t *attr) const
+{
+        vte::grid::coords coords {row, col};
+
+        for (guint i = 0; i < m_matches->len; i++) {
+                struct HiliteMatch *match = &g_array_index (m_matches, struct HiliteMatch, i);
+                if (coords >= match->start && coords < match->end) {
+                        *fore &= ~match->foremask;
+                        *fore |= match->fore;
+                        *back &= ~match->backmask;
+                        *back |= match->back;
+                        *deco &= ~match->decomask;
+                        *deco |= match->deco;
+                        *attr &= ~match->attrmask;
+                        *attr |= match->attr;
+                        return;
+                }
+        }
+}
diff --git a/src/hilite.hh b/src/hilite.hh
new file mode 100644
index 00000000..ef1fefdf
--- /dev/null
+++ b/src/hilite.hh
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2018–2019 Egmont Koblinger
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ */
+
+#pragma once
+
+#include <glib.h>
+
+#include "ring.hh"
+#include "ringview.hh"
+#include "vterowdata.hh"
+#include "vtetypes.hh"
+#include "vteunistr.h"
+
+namespace vte {
+
+namespace base {
+
+class RingView;
+
+struct HiliteMatch {
+        vte::grid::coords start;
+        vte::grid::coords end;    /* exclusive */
+        uint32_t fore, foremask;
+        uint32_t back, backmask;
+        uint32_t deco, decomask;
+        uint32_t attr, attrmask;
+};
+
+class Hilite {
+public:
+        Hilite();
+        ~Hilite();
+
+        // prevent accidents
+        Hilite(Hilite& o) = delete;
+        Hilite(Hilite const& o) = delete;
+        Hilite(Hilite&& o) = delete;
+        Hilite& operator= (Hilite& o) = delete;
+        Hilite& operator= (Hilite const& o) = delete;
+        Hilite& operator= (Hilite&& o) = delete;
+
+        void clear();
+        void paragraph(RingView *ringview, vte::grid::row_t start, vte::grid::row_t end);
+
+        void highlight(vte::grid::row_t row,
+                       vte::grid::column_t col,
+                       uint32_t *fore,
+                       uint32_t *back,
+                       uint32_t *deco,
+                       uint32_t *attr) const;
+
+private:
+        GArray *m_matches;  /* array of HiliteMatch entries */
+
+        void find_word(const char *haystack, const char *needle, GArray *map,
+                       uint32_t foremask, uint32_t fore,
+                       uint32_t backmask, uint32_t back,
+                       uint32_t decomask, uint32_t deco,
+                       uint32_t attrmask, uint32_t attr);
+};
+
+}; /* namespace base */
+
+}; /* namespace vte */
diff --git a/src/meson.build b/src/meson.build
index 1481c089..b92328ad 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -64,6 +64,8 @@ libvte_common_sources = debug_sources + modes_sources + parser_sources + utf8_so
   'chunk.cc',
   'chunk.hh',
   'color-triple.hh',
+  'hilite.cc',
+  'hilite.hh',
   'keymap.cc',
   'keymap.h',
   'pty.cc',
diff --git a/src/ringview.cc b/src/ringview.cc
index 602268a6..6eb99947 100644
--- a/src/ringview.cc
+++ b/src/ringview.cc
@@ -20,6 +20,7 @@
 
 #include "bidi.hh"
 #include "debug.h"
+#include "hilite.hh"
 #include "vtedefines.hh"
 #include "vteinternal.hh"
 
@@ -67,6 +68,8 @@ RingView::pause()
         g_free (m_bidirows);
         m_bidirows_alloc_len = 0;
 
+        delete m_hilite;
+
         m_invalid = true;
         m_paused = true;
 }
@@ -97,6 +100,8 @@ RingView::resume()
                 m_bidirows[i] = new BidiRow();
         }
 
+        m_hilite = new Hilite();
+
         _vte_debug_print (VTE_DEBUG_RINGVIEW, "Ringview: resume, allocating %d rows, %d bidirows\n",
                                               m_rows_alloc_len, m_bidirows_alloc_len);
 
@@ -272,6 +277,8 @@ RingView::update()
                                               m_start - m_top, (m_top + m_rows_len) - (m_start + m_len),
                                               m_top, m_top + m_rows_len - 1, m_rows_len);
 
+        m_hilite->clear();
+
         /* Loop through paragraphs of the extracted text, and do whatever we need to do on each paragraph. */
         auto top = m_top;
         row = top;
@@ -285,6 +292,7 @@ RingView::update()
                                                 m_enable_bidi, m_enable_shaping);
 
                         /* Doing syntax highlighting etc. come here in the future. */
+                        m_hilite->paragraph(this, top, row + 1);
 
                         top = row + 1;
                 }
diff --git a/src/ringview.hh b/src/ringview.hh
index 09ff0498..8b4a6c81 100644
--- a/src/ringview.hh
+++ b/src/ringview.hh
@@ -21,6 +21,7 @@
 #include <glib.h>
 
 #include "bidi.hh"
+#include "hilite.hh"
 #include "ring.hh"
 #include "vterowdata.hh"
 #include "vtetypes.hh"
@@ -32,6 +33,7 @@ namespace base {
 
 class BidiRow;
 class BidiRunner;
+class Hilite;
 
 /*
  * RingView provides a "view" to a continuous segment of the Ring (or stream),
@@ -83,6 +85,8 @@ public:
 
         BidiRow const* get_bidirow(vte::grid::row_t row) const;
 
+        Hilite const* get_hilite() const { return m_hilite; }
+
 private:
         Ring *m_ring{nullptr};
 
@@ -97,6 +101,8 @@ private:
 
         std::unique_ptr<BidiRunner> m_bidirunner;
 
+        Hilite *m_hilite;
+
         vte::grid::row_t m_top{0};  /* the row of the Ring corresponding to m_rows[0] */
 
         vte::grid::row_t m_start{0};
diff --git a/src/vte.cc b/src/vte.cc
index 1235ee29..6cb4df74 100644
--- a/src/vte.cc
+++ b/src/vte.cc
@@ -9281,6 +9281,7 @@ Terminal::draw_rows(VteScreen *screen_,
                         /* Find the colors for this cell. */
                         selected = cell_is_selected_vis(i, row);
                         determine_colors(cell, selected, &fore, &back, &deco);
+                        m_ringview.get_hilite()->highlight(row, bidirow->vis2log(i), &fore, &back, &deco, &attr);
                         rtl = bidirow->vis_is_rtl(i);
 
                         while (++j < column_count) {
@@ -9291,6 +9292,7 @@ Terminal::draw_rows(VteScreen *screen_,
                                  * in this chunk. */
                                 selected = cell_is_selected_vis(j, row);
                                 determine_colors(cell, selected, &nfore, &nback, &ndeco);
+                                m_ringview.get_hilite()->highlight(row, bidirow->vis2log(j), &nfore, &nback, &ndeco, &nattr);
                                 nrtl = bidirow->vis_is_rtl(j);
                                 if (nback != back || (_vte_debug_on (VTE_DEBUG_BIDI) && nrtl != rtl)) {
                                         break;
@@ -9406,6 +9408,7 @@ Terminal::draw_rows(VteScreen *screen_,
                         nattr = cell->attr.attr;
                         selected = cell_is_selected_log(lcol, row);
                         determine_colors(cell, selected, &nfore, &nback, &ndeco);
+                        m_ringview.get_hilite()->highlight(row, lcol, &nfore, &nback, &ndeco, &nattr);
 
                         nhilite = (nhyperlink && cell->attr.hyperlink_idx == m_hyperlink_hover_idx) ||
                                   (!nhyperlink && m_match != nullptr && m_match_span.contains(row, lcol));
