Implemented suggestion bolding.

The part of the query that the user did not type is highlighed, currently in bold.

Bug: 2806529.

Change-Id: I087c4cd80faf878a54e1fa8d4c2833eef4329a62
diff --git a/src/com/android/quicksearchbox/LevenshteinSuggestionFormatter.java b/src/com/android/quicksearchbox/LevenshteinSuggestionFormatter.java
new file mode 100644
index 0000000..418a7a9
--- /dev/null
+++ b/src/com/android/quicksearchbox/LevenshteinSuggestionFormatter.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quicksearchbox;
+
+import com.android.quicksearchbox.util.LevenshteinDistance;
+import com.google.common.annotations.VisibleForTesting;
+
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Suggestion formatter using the Levenshtein distance (minumum edit distance) to calculate the
+ * formatting.
+ */
+public class LevenshteinSuggestionFormatter extends SuggestionFormatter {
+    private static final boolean DBG = false;
+    private static final String TAG = "QSB.LevenshteinSuggestionFormatter";
+
+    @Override
+    public Spanned formatSuggestion(CharSequence query, CharSequence suggestion) {
+        if (DBG) Log.d(TAG, "formatSuggestion('" + query + "', '" + suggestion + "')");
+        query = normalizeQuery(query);
+        List<Token> queryTokens = tokenize(query);
+        List<Token> suggestionTokens = tokenize(suggestion);
+        int[] matches = findMatches(queryTokens, suggestionTokens);
+        if (DBG){
+            Log.d(TAG, "source = " + queryTokens);
+            Log.d(TAG, "target = " + suggestionTokens);
+            Log.d(TAG, "matches = " + matches);
+        }
+        SpannableString str = new SpannableString(suggestion);
+
+        for (int i = 0; i < matches.length; ++i) {
+            Token t = suggestionTokens.get(i);
+            int sourceLen = 0;
+            if (matches[i] >= 0) {
+                sourceLen = queryTokens.get(matches[i]).getLength();
+            }
+            applySuggestedTextStyle(str, t.getStart() + sourceLen, t.getEnd());
+            applyQueryTextStyle(str, t.getStart(), t.getStart() + sourceLen);
+        }
+
+        return str;
+    }
+
+    private CharSequence normalizeQuery(CharSequence query) {
+        return query.toString().toLowerCase();
+    }
+
+    /**
+     * Finds which tokens in the target match tokens in the source.
+     *
+     * @param source List of source tokens (i.e. user query)
+     * @param target List of target tokens (i.e. suggestion)
+     * @return The indices into source which target tokens correspond to. A non-negative value n at
+     *      position i means that target token i matches source token n. A negative value means that
+     *      the target token i does not match any source token.
+     */
+    @VisibleForTesting
+    int[] findMatches(List<Token> source, List<Token> target) {
+        LevenshteinDistance<Token> table = new LevenshteinDistance<Token>(source, target) {
+            @Override
+            protected boolean match(Token source, Token target) {
+                return source.prefixOf(target);
+            }
+        };
+        table.calculate();
+        int[] result = new int[target.size()];
+        LevenshteinDistance.EditOperation[] ops = table.getTargetOperations();
+        for (int i = 0; i < result.length; ++i) {
+            if (ops[i].getType() == LevenshteinDistance.EDIT_UNCHANGED) {
+                result[i] = ops[i].getPosition();
+            } else {
+                result[i] = -1;
+            }
+        }
+        return result;
+    }
+
+    @VisibleForTesting
+    List<Token> tokenize(CharSequence seq) {
+        int pos = 0;
+        ArrayList<Token> tokens = new ArrayList<Token>();
+        while (pos < seq.length()) {
+            while (pos < seq.length() && Character.isWhitespace(seq.charAt(pos))) {
+                pos++;
+            }
+            int start = pos;
+            while (pos < seq.length() && !Character.isWhitespace(seq.charAt(pos))) {
+                pos++;
+            }
+            int end = pos;
+            if (start != end) {
+                Token t = new Token(seq, start, end);
+                tokens.add(t);
+            }
+        }
+        return tokens;
+    }
+
+    @VisibleForTesting
+    static class Token {
+        private final CharSequence mContainer;
+        private final int mStart;
+        private final int mEnd;
+
+        public Token(CharSequence container, int start, int end) {
+            mContainer = container;
+            mStart = start;
+            mEnd = end;
+        }
+
+        public int getStart() {
+            return mStart;
+        }
+
+        public int getEnd() {
+            return mEnd;
+        }
+
+        public int getLength() {
+            return mEnd - mStart;
+        }
+
+        @Override
+        public String toString() {
+            return getSequence().toString();
+        }
+
+        public CharSequence getSequence() {
+            return mContainer.subSequence(mStart, mEnd);
+        }
+
+        public boolean prefixOf(Token that) {
+            return that.toString().startsWith(toString());
+        }
+
+    }
+
+}
diff --git a/src/com/android/quicksearchbox/QsbApplication.java b/src/com/android/quicksearchbox/QsbApplication.java
index b9ec771..2b0b0f5 100644
--- a/src/com/android/quicksearchbox/QsbApplication.java
+++ b/src/com/android/quicksearchbox/QsbApplication.java
@@ -33,6 +33,7 @@
 import android.content.Context;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
+import android.graphics.Typeface;
 import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
@@ -61,6 +62,7 @@
     private GoogleSource mGoogleSource;
     private VoiceSearch mVoiceSearch;
     private Logger mLogger;
+    private SuggestionFormatter mSuggestionFormatter;
 
     public QsbApplication(Context context) {
         mContext = context;
@@ -398,4 +400,16 @@
     protected Logger createLogger() {
         return new EventLogLogger(getContext(), getConfig());
     }
+
+    public SuggestionFormatter getSuggestionFormatter() {
+        if (mSuggestionFormatter == null) {
+            mSuggestionFormatter = createSuggestionFormatter();
+            mSuggestionFormatter.setSuggestedTextFormat(Typeface.BOLD, 0);
+        }
+        return mSuggestionFormatter;
+    }
+
+    protected SuggestionFormatter createSuggestionFormatter() {
+        return new LevenshteinSuggestionFormatter();
+    }
 }
diff --git a/src/com/android/quicksearchbox/SuggestionFormatter.java b/src/com/android/quicksearchbox/SuggestionFormatter.java
new file mode 100644
index 0000000..37fe68d
--- /dev/null
+++ b/src/com/android/quicksearchbox/SuggestionFormatter.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quicksearchbox;
+
+import android.text.Spannable;
+import android.text.style.StyleSpan;
+
+/**
+ * Suggestion formatter interface. This is used to bold (or otherwise highlight) portions of a
+ * suggestion which were not a part of the query.
+ */
+public abstract class SuggestionFormatter {
+
+    private int mQueryTextStyle;
+    private int mQueryStyleFlags;
+
+    private int mSuggestedTextStyle;
+    private int mSuggestedStyleFlags;
+
+
+    protected SuggestionFormatter() {
+    }
+
+    /**
+     * Formats a suggestion for display in the UI.
+     *
+     * @param query the query as entered by the user
+     * @param suggestion the suggestion
+     * @return Formatted suggestion text.
+     */
+    public abstract CharSequence formatSuggestion(CharSequence query, CharSequence suggestion);
+
+    /**
+     * Sets the text format to use for portions of a suggestions which form a part of the original
+     * used query.
+     *
+     * @param style Style for query text. Values are constants defined in
+     *      {@link android.graphics.Typeface}
+     * @param flags Flags  to determine how the span will behave when text is inserted at the start
+     *      or end of the span's range.
+     */
+    public void setQueryTextFormat(int style, int flags) {
+        mQueryTextStyle = style;
+        mQueryStyleFlags = flags;
+    }
+
+    /**
+     * Sets the text format to use for portions of a suggestion which are new.
+     *
+     * @param style Style for suggested text. Values are constants defined in
+     *      {@link android.graphics.Typeface}
+     * @param flags Flags to determine how the span will behave when text is inserted at the start
+     *      or end of the span's range.
+     */
+    public void setSuggestedTextFormat(int style, int flags) {
+        mSuggestedTextStyle = style;
+        mSuggestedStyleFlags = flags;
+    }
+
+    protected void applyQueryTextStyle(Spannable text, int start, int end) {
+        if (start == end) return;
+        StyleSpan style = new StyleSpan(mQueryTextStyle);
+        text.setSpan(style, start, end, mQueryStyleFlags);
+    }
+
+    protected void applySuggestedTextStyle(Spannable text, int start, int end) {
+        if (start == end) return;
+        StyleSpan style = new StyleSpan(mSuggestedTextStyle);
+        text.setSpan(style, start, end, mSuggestedStyleFlags);
+    }
+
+}
diff --git a/src/com/android/quicksearchbox/SuggestionNonFormatter.java b/src/com/android/quicksearchbox/SuggestionNonFormatter.java
new file mode 100644
index 0000000..379d484
--- /dev/null
+++ b/src/com/android/quicksearchbox/SuggestionNonFormatter.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quicksearchbox;
+
+
+/**
+ * Basic SuggestionFormatter that does no formatting.
+ */
+public class SuggestionNonFormatter extends SuggestionFormatter {
+
+    public SuggestionNonFormatter() {
+    }
+
+    @Override
+    public CharSequence formatSuggestion(CharSequence query, CharSequence suggestion) {
+        return suggestion;
+    }
+
+}
diff --git a/src/com/android/quicksearchbox/ui/DefaultSuggestionView.java b/src/com/android/quicksearchbox/ui/DefaultSuggestionView.java
index 28d3e3d..e7c1249 100644
--- a/src/com/android/quicksearchbox/ui/DefaultSuggestionView.java
+++ b/src/com/android/quicksearchbox/ui/DefaultSuggestionView.java
@@ -16,10 +16,12 @@
 
 package com.android.quicksearchbox.ui;
 
+import com.android.quicksearchbox.QsbApplication;
 import com.android.quicksearchbox.R;
 import com.android.quicksearchbox.Source;
 import com.android.quicksearchbox.Suggestion;
 import com.android.quicksearchbox.SuggestionCursor;
+import com.android.quicksearchbox.SuggestionFormatter;
 
 import android.content.Context;
 import android.content.res.ColorStateList;
@@ -50,17 +52,21 @@
     private TextView mText2;
     private ImageView mIcon1;
     private ImageView mIcon2;
+    private final SuggestionFormatter mSuggestionFormatter;
 
     public DefaultSuggestionView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-    }
+        mSuggestionFormatter = QsbApplication.get(context).getSuggestionFormatter();
+        }
 
     public DefaultSuggestionView(Context context, AttributeSet attrs) {
         super(context, attrs);
+        mSuggestionFormatter = QsbApplication.get(context).getSuggestionFormatter();
     }
 
     public DefaultSuggestionView(Context context) {
         super(context);
+        mSuggestionFormatter = QsbApplication.get(context).getSuggestionFormatter();
     }
 
     @Override
@@ -73,13 +79,12 @@
     }
 
     public void bindAsSuggestion(SuggestionCursor suggestion) {
-        String format = suggestion.getSuggestionFormat();
-        CharSequence text1 = formatText(suggestion.getSuggestionText1(), format);
+        CharSequence text1 = formatText(suggestion.getSuggestionText1(), suggestion, true);
         CharSequence text2 = suggestion.getSuggestionText2Url();
         if (text2 != null) {
             text2 = formatUrl(text2);
         } else {
-            text2 = formatText(suggestion.getSuggestionText2(), format);
+            text2 = formatText(suggestion.getSuggestionText2(), suggestion, false);
         }
         Drawable icon1 = getSuggestionDrawableIcon1(suggestion);
         Drawable icon2 = getSuggestionDrawableIcon2(suggestion);
@@ -151,10 +156,14 @@
         return iconId == null ? null : source.getIcon(iconId);
     }
 
-    private CharSequence formatText(String str, String format) {
-        boolean isHtml = "html".equals(format);
+    private CharSequence formatText(String str, SuggestionCursor suggestion,
+                boolean highlightSuggested) {
+        boolean isHtml = "html".equals(suggestion.getSuggestionFormat());
         if (isHtml && looksLikeHtml(str)) {
             return Html.fromHtml(str);
+        } else if (highlightSuggested && suggestion.isWebSearchSuggestion() &&
+                !TextUtils.isEmpty(suggestion.getUserQuery())) {
+            return mSuggestionFormatter.formatSuggestion(suggestion.getUserQuery(), str);
         } else {
             return str;
         }
diff --git a/src/com/android/quicksearchbox/util/LevenshteinDistance.java b/src/com/android/quicksearchbox/util/LevenshteinDistance.java
new file mode 100644
index 0000000..f5b99ec
--- /dev/null
+++ b/src/com/android/quicksearchbox/util/LevenshteinDistance.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quicksearchbox.util;
+
+import java.util.List;
+
+/**
+ * This class represents the matrix used in the Levenshtein distance algorithm, together
+ * with the algorithm itself which operates on the matrix.
+ *
+ * We also track of the individual operations applied to transform the source string into the
+ * target string so we can trace the path taken through the matrix afterwards, in order to
+ * perform the formatting as required.
+ */
+public abstract class LevenshteinDistance<T> {
+    public static final int EDIT_DELETE = 0;
+    public static final int EDIT_INSERT = 1;
+    public static final int EDIT_REPLACE = 2;
+    public static final int EDIT_UNCHANGED = 3;
+
+    private final List<T> mSource;
+    private final List<T> mTarget;
+    private final Entry[][] mTable;
+
+    public LevenshteinDistance(List<T> source, List<T> target) {
+        mTable = new Entry[source.size()+1][target.size()+1];
+        mSource = source;
+        mTarget = target;
+        set(0, 0, EDIT_UNCHANGED, 0);
+        for (int i = 1; i <= source.size(); ++i) {
+            set(i, 0, EDIT_DELETE, i);
+        }
+        for (int i = 1; i <= target.size(); ++i) {
+            set(0, i, EDIT_INSERT, i);
+        }
+    }
+
+    /**
+     * Compares a source and target token.
+     * @param source Token from the source string.
+     * @param target Token from the target string.
+     * @return {@code true} if the two are the same for the purposes of edit distance calculation,
+     *      {@code false} otherwise.
+     */
+    protected abstract boolean match(T source, T target);
+
+    /**
+     * Implementation of Levenshtein distance algorithm.
+     *
+     * @return The Levenshtein distance.
+     */
+    public int calculate() {
+        for (int s = 1; s <= mSource.size(); ++s) {
+            T sourceToken = mSource.get(s-1);
+            for (int t = 1; t <= mTarget.size(); ++t) {
+                T targetToken = mTarget.get(t-1);
+                int cost = match(sourceToken, targetToken) ? 0 : 1;
+
+                Entry e = get(s - 1, t);
+                int distance = e.getDistance() + 1;
+                int type = EDIT_DELETE;
+
+                e = get(s, t - 1);
+                if (e.getDistance() + 1 < distance ) {
+                    distance = e.getDistance() + 1;
+                    type = EDIT_INSERT;
+                }
+
+                e = get(s - 1, t - 1);
+                if (e.getDistance() + cost < distance) {
+                    distance = e.getDistance() + cost;
+                    type = cost == 0 ? EDIT_UNCHANGED : EDIT_REPLACE;
+                }
+                set(s, t, type, distance);
+            }
+        }
+        return get(mSource.size(), mTarget.size()).getDistance();
+    }
+
+    /**
+     * Gets the list of operations which were applied to each target token; {@link #calculate} must
+     * have been called on this object before using this method.
+     * @return A list of {@link EditOperation}s indicating the origin of each token in the target
+     *      string. The position of the token indicates the position in the source string of the
+     *      token that was unchanged/replaced, or the position in the source after which a target
+     *      token was inserted.
+     */
+    public EditOperation[] getTargetOperations() {
+        EditOperation[] ops = new EditOperation[mTarget.size()];
+        int targetPos = mTarget.size();
+        int sourcePos = mSource.size();
+        while (targetPos > 0) {
+            Entry e = get(sourcePos, targetPos);
+            int editType = e.getEditType();
+            switch (editType) {
+                case LevenshteinDistance.EDIT_DELETE:
+                    sourcePos--;
+                    break;
+                case LevenshteinDistance.EDIT_INSERT:
+                    targetPos--;
+                    ops[targetPos] = new EditOperation(editType, sourcePos);
+                    break;
+                case LevenshteinDistance.EDIT_UNCHANGED:
+                case LevenshteinDistance.EDIT_REPLACE:
+                    targetPos--;
+                    sourcePos--;
+                    ops[targetPos] = new EditOperation(editType, sourcePos);
+                    break;
+            }
+        }
+
+        return ops;
+    }
+
+    private void set(int sourceIdx, int targetIdx, int editType, int distance) {
+        mTable[sourceIdx][targetIdx] = new Entry(editType, distance);
+    }
+
+    private Entry get(int sourceIdx, int targetIdx) {
+        Entry e = mTable[sourceIdx][targetIdx];
+        if (e == null) {
+            throw new NullPointerException("No entry at " + sourceIdx + "," + targetIdx +
+                    "; size: " + (mSource.size() + 1) + "," + (mTarget.size() + 1));
+        }
+        return e;
+    }
+
+    private static class Entry {
+        private final int mEditType;
+        private final int mDistance;
+        public Entry(int editType, int distance) {
+            mEditType = editType;
+            mDistance = distance;
+        }
+        public int getDistance() {
+            return mDistance;
+        }
+        public int getEditType() {
+            return mEditType;
+        }
+    }
+
+    public static class EditOperation {
+        private final int mType;
+        private final int mPosition;
+        public EditOperation(int type, int position) {
+            mType = type;
+            mPosition = position;
+        }
+        public int getType() {
+            return mType;
+        }
+        public int getPosition() {
+            return mPosition;
+        }
+    }
+
+}
diff --git a/tests/src/com/android/quicksearchbox/LevenshteinFormatterTest.java b/tests/src/com/android/quicksearchbox/LevenshteinFormatterTest.java
new file mode 100644
index 0000000..efde9e6
--- /dev/null
+++ b/tests/src/com/android/quicksearchbox/LevenshteinFormatterTest.java
@@ -0,0 +1,361 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quicksearchbox;
+
+import android.graphics.Typeface;
+import android.test.AndroidTestCase;
+import android.text.Spanned;
+import android.text.style.StyleSpan;
+
+import java.util.List;
+
+/**
+ * Tests for {@link LevenshteinSuggestionFormatter}.
+ */
+public class LevenshteinFormatterTest extends AndroidTestCase {
+
+    private LevenshteinSuggestionFormatter mFormatter;
+    private int mSuggestedStyle;
+    private int mQueryStyle;
+
+    @Override
+    protected void setUp() throws Exception {
+        mFormatter = new LevenshteinSuggestionFormatter();
+        mSuggestedStyle = Typeface.BOLD;
+        mQueryStyle = Typeface.ITALIC;
+        mFormatter.setSuggestedTextFormat(mSuggestedStyle, 0);
+        mFormatter.setQueryTextFormat(mQueryStyle, 0);
+    }
+
+    private void verifyTokenizeResult(String input, String... output) {
+        List<LevenshteinSuggestionFormatter.Token> tokens = mFormatter.tokenize(input);
+        assertEquals(output.length, tokens.size());
+        for (int i=0; i<output.length; ++i) {
+            assertEquals(output[i], tokens.get(i).toString());
+        }
+    }
+
+    public void testTokenizeNoTokens() {
+        verifyTokenizeResult("");
+        verifyTokenizeResult("  ");
+    }
+
+    public void testTokenizeSingleToken() {
+        verifyTokenizeResult("singleToken", "singleToken");
+    }
+
+    public void testTokenizeTwoTokens() {
+        verifyTokenizeResult("two tokens", "two", "tokens");
+    }
+
+    public void testTokenizeLeadingSpaces() {
+        verifyTokenizeResult(" evil kittens", "evil", "kittens");
+        verifyTokenizeResult("        furry lizards", "furry", "lizards");
+    }
+
+    public void testTokenizeTrailingSpaces() {
+        verifyTokenizeResult("mechanical elephant ", "mechanical", "elephant");
+        verifyTokenizeResult("disappointed dog       ", "disappointed", "dog");
+    }
+
+    public void testTokenizeManySpaces() {
+        verifyTokenizeResult("happy     horses", "happy", "horses");
+    }
+
+    public void testTokenizeLongSentence() {
+        verifyTokenizeResult("The fool looks at a finger that points at the sky",
+                "The", "fool", "looks", "at", "a", "finger", "that", "points", "at", "the", "sky");
+    }
+
+    public void testTokenizeWithPunctuation() {
+        verifyTokenizeResult("Hitchhiker's guide", "Hitchhiker's", "guide");
+        verifyTokenizeResult("full. stop. ", "full.", "stop.");
+        verifyTokenizeResult("' . ; . ..", "'", ".", ";", ".", "..");
+    }
+
+    public void testTokenizeWithTabsAndNewLines() {
+        verifyTokenizeResult("paranoid\nandroid\t", "paranoid", "android");
+    }
+
+    private void verifyFindMatches(String source, String target, String... newTokensInTarget) {
+        List<LevenshteinSuggestionFormatter.Token> sourceTokens = mFormatter.tokenize(source);
+        List<LevenshteinSuggestionFormatter.Token> targetTokens = mFormatter.tokenize(target);
+
+        int[] matches = mFormatter.findMatches(sourceTokens, targetTokens);
+        assertEquals(targetTokens.size(), matches.length);
+        int newTokenCount = 0;
+        int lastSourceToken = -1;
+        for (int i=0; i<targetTokens.size(); ++i) {
+
+            int sourceIdx = matches[i];
+            if (sourceIdx < 0) {
+                String targetToken = targetTokens.get(i).toString();
+                assertTrue("Unexpected new token '" + targetToken + "'",
+                        newTokenCount < newTokensInTarget.length);
+
+                assertEquals(newTokensInTarget[newTokenCount], targetToken);
+                ++newTokenCount;
+            } else {
+                assertTrue("Source token out of order", lastSourceToken < sourceIdx);
+                LevenshteinSuggestionFormatter.Token srcToken = sourceTokens.get(sourceIdx);
+                LevenshteinSuggestionFormatter.Token trgToken = targetTokens.get(i);
+                assertTrue("'" + srcToken + "' is not a prefix of '" + trgToken + "'",
+                        srcToken.prefixOf(trgToken));
+                lastSourceToken = sourceIdx;
+            }
+        }
+    }
+
+    public void testFindMatchesSameTokens() {
+        verifyFindMatches("", "");
+        verifyFindMatches("one", "one");
+        verifyFindMatches("one two three", "one two three");
+    }
+
+    public void testFindMatchesNewTokens() {
+        verifyFindMatches("", "one", "one");
+        verifyFindMatches("one", "one two", "two");
+        verifyFindMatches("one", "one two three", "two", "three");
+        verifyFindMatches("two", "one two three", "one", "three");
+        verifyFindMatches("pictures", "pictures of kittens", "of", "kittens");
+    }
+
+    public void testFindMatchesReplacedTokens() {
+        verifyFindMatches("one", "two", "two");
+        verifyFindMatches("one", "two three", "two", "three");
+        verifyFindMatches("two", "one three", "one", "three");
+        verifyFindMatches("pictures", "of kittens", "of", "kittens");
+    }
+
+    public void testFindMatchesDuplicateTokens() {
+        verifyFindMatches("badger", "badger badger", "badger");
+        verifyFindMatches("badger", "badger badger badger", "badger", "badger");
+        verifyFindMatches("badger badger", "badger badger badger", "badger");
+        verifyFindMatches("badger badger badger", "badger badger badger");
+        // mushroom!
+    }
+
+    private void verifyFormatSuggestion(String query, String suggestion, SpanFormat... spans) {
+        Spanned s = mFormatter.formatSuggestion(query, suggestion);
+        for (SpanFormat span : spans) {
+            span.verify(s);
+        }
+    }
+
+    public void testFormatSuggestionEmptyStrings() {
+        verifyFormatSuggestion("", "");
+    }
+
+    public void testFormatSuggestionEmptyQuery() {
+        verifyFormatSuggestion("", "suggestion",
+                new SpanFormat(0, "suggestion", mSuggestedStyle));
+    }
+
+    public void testFormatSuggestionQuerySuggested() {
+        verifyFormatSuggestion("query", "query",
+                new SpanFormat(0, "query", mQueryStyle));
+    }
+
+    public void testFormatSuggestionExtraWordsSuggested() {
+        verifyFormatSuggestion("query", "query suggested",
+                new SpanFormat(0, "query", mQueryStyle),
+                new SpanFormat(6, "suggested", mSuggestedStyle));
+
+        verifyFormatSuggestion("pictures", "pictures of kittens",
+                new SpanFormat(0,  "pictures", mQueryStyle),
+                new SpanFormat(9,  "of", mSuggestedStyle),
+                new SpanFormat(12, "kittens", mSuggestedStyle));
+
+        verifyFormatSuggestion("pictures of", "pictures of kittens dying",
+                new SpanFormat(0,  "pictures", mQueryStyle),
+                new SpanFormat(9,  "of", mQueryStyle),
+                new SpanFormat(12, "kittens", mSuggestedStyle),
+                new SpanFormat(20, "dying", mSuggestedStyle));
+    }
+
+    public void testFormatSuggestionExtraWordSuggestedAtStart() {
+        verifyFormatSuggestion("query", "suggested query",
+                new SpanFormat(0, "suggested", mSuggestedStyle),
+                new SpanFormat(10, "query", mQueryStyle));
+    }
+
+    public void testFormatSuggestionAlternativeWordSuggested() {
+        verifyFormatSuggestion("query", "suggested",
+                new SpanFormat(0, "suggested", mSuggestedStyle));
+    }
+
+    public void testFormatSuggestionDuplicateWords() {
+        verifyFormatSuggestion("", "badger badger",
+                new SpanFormat(0, "badger", mSuggestedStyle),
+                new SpanFormat(7, "badger", mSuggestedStyle));
+
+        verifyFormatSuggestion("badger", "badger badger",
+                new SpanFormat(0, "badger", mQueryStyle),
+                new SpanFormat(7, "badger", mSuggestedStyle));
+
+        verifyFormatSuggestion("badger badger", "badger badger",
+                new SpanFormat(0, "badger", mQueryStyle),
+                new SpanFormat(7, "badger", mQueryStyle));
+
+        verifyFormatSuggestion("badger badger", "badger badger badger",
+                new SpanFormat(0, "badger", mQueryStyle),
+                new SpanFormat(7, "badger", mQueryStyle),
+                new SpanFormat(14, "badger", mSuggestedStyle));
+    }
+
+    public void testFormatSuggestionDuplicateSequences() {
+        verifyFormatSuggestion("dem bones", "dem bones dem bones",
+                new SpanFormat(0, "dem", mQueryStyle),
+                new SpanFormat(4, "bones", mQueryStyle),
+                new SpanFormat(10, "dem", mSuggestedStyle),
+                new SpanFormat(14, "bones", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("dem bones", "dem dry bones dem bones",
+                new SpanFormat(0, "dem", mQueryStyle),
+                new SpanFormat(4, "dry", mSuggestedStyle),
+                new SpanFormat(8, "bones", mQueryStyle),
+                new SpanFormat(14, "dem", mSuggestedStyle),
+                new SpanFormat(18, "bones", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("dem dry bones", "dry bones dem dry bones dem dry bones",
+                new SpanFormat(0, "dry", mSuggestedStyle),
+                new SpanFormat(4, "bones", mSuggestedStyle),
+                new SpanFormat(10, "dem", mQueryStyle),
+                new SpanFormat(14, "dry", mQueryStyle),
+                new SpanFormat(18, "bones", mQueryStyle),
+                new SpanFormat(24, "dem", mSuggestedStyle),
+                new SpanFormat(28, "dry", mSuggestedStyle),
+                new SpanFormat(32, "bones", mSuggestedStyle)
+        );
+    }
+
+    public void testFormatSuggestionWordCompletion() {
+        verifyFormatSuggestion("hitch", "hitchhiker",
+                new SpanFormat(0, "hitch", mQueryStyle),
+                new SpanFormat(5, "hiker", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("hitch", "hitchhiker's guide",
+                new SpanFormat(0, "hitch", mQueryStyle),
+                new SpanFormat(5, "hiker's", mSuggestedStyle),
+                new SpanFormat(13, "guide", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("hitchhiker's g", "hitchhiker's guide",
+                new SpanFormat(0, "hitchhiker's", mQueryStyle),
+                new SpanFormat(13, "g", mQueryStyle),
+                new SpanFormat(14, "uide", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("hitchhiker's g", "hitchhiker's guide to the galaxy",
+                new SpanFormat(0, "hitchhiker's", mQueryStyle),
+                new SpanFormat(13, "g", mQueryStyle),
+                new SpanFormat(14, "uide", mSuggestedStyle),
+                new SpanFormat(19, "to", mSuggestedStyle),
+                new SpanFormat(22, "the", mSuggestedStyle),
+                new SpanFormat(26, "galaxy", mSuggestedStyle)
+        );
+    }
+
+    public void testFormatSuggestionWordSplitting() {
+        verifyFormatSuggestion("dimsum", "dim sum",
+                new SpanFormat(0, "dim", mSuggestedStyle),
+                new SpanFormat(4, "sum", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("dimsum london", "dim sum london",
+                new SpanFormat(0, "dim", mSuggestedStyle),
+                new SpanFormat(4, "sum", mSuggestedStyle),
+                new SpanFormat(8, "london", mQueryStyle)
+        );
+
+        verifyFormatSuggestion("dimsum london", "dim sum london yummy",
+                new SpanFormat(0, "dim", mSuggestedStyle),
+                new SpanFormat(4, "sum", mSuggestedStyle),
+                new SpanFormat(8, "london", mQueryStyle),
+                new SpanFormat(15, "yummy", mSuggestedStyle)
+        );
+    }
+
+    public void testFormatSuggestionWordCombining() {
+        verifyFormatSuggestion("hos pital", "hospital",
+                new SpanFormat(0, "hos", mQueryStyle),
+                new SpanFormat(3, "pital", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("hos pital", "hospital waiting times",
+                new SpanFormat(0, "hos", mQueryStyle),
+                new SpanFormat(3, "pital", mSuggestedStyle),
+                new SpanFormat(9, "waiting", mSuggestedStyle),
+                new SpanFormat(17, "times", mSuggestedStyle)
+        );
+
+        verifyFormatSuggestion("hos pital waiting", "hospital waiting",
+                new SpanFormat(0, "hos", mQueryStyle),
+                new SpanFormat(3, "pital", mSuggestedStyle),
+                new SpanFormat(9, "waiting", mQueryStyle)
+        );
+
+        verifyFormatSuggestion("hospital wait ing times", "hospital waiting times",
+                new SpanFormat(0, "hospital", mQueryStyle),
+                new SpanFormat(9, "wait", mQueryStyle),
+                new SpanFormat(13, "ing", mSuggestedStyle),
+                new SpanFormat(17, "times", mQueryStyle)
+        );
+    }
+
+    public void testFormatSuggestionCapitalization() {
+        verifyFormatSuggestion("Flay", "flay",
+                new SpanFormat(0, "flay", mQueryStyle));
+
+        verifyFormatSuggestion("STEERPI", "steerpike",
+                new SpanFormat(0, "steerpi", mQueryStyle),
+                new SpanFormat(7, "ke", mSuggestedStyle));
+
+        verifyFormatSuggestion("STEErpi", "steerpike",
+                new SpanFormat(0, "steerpi", mQueryStyle),
+                new SpanFormat(7, "ke", mSuggestedStyle));
+
+        verifyFormatSuggestion("TITUS", "titus groan",
+                new SpanFormat(0, "titus", mQueryStyle),
+                new SpanFormat(6, "groan", mSuggestedStyle));
+}
+
+    private class SpanFormat {
+        private final int mStart;
+        private final int mEnd;
+        private final String mExpectedText;
+        private final int mStyle;
+        public SpanFormat(int start, String expectedText, int style) {
+            mStart = start;
+            mEnd = start + expectedText.length();
+            mExpectedText = expectedText;
+            mStyle = style;
+        }
+        public void verify(Spanned spanned) {
+            String spannedText = spanned.subSequence(mStart, mEnd).toString();
+            assertEquals("Test error", mExpectedText, spannedText);
+            StyleSpan[] spans = spanned.getSpans(mStart, mEnd, StyleSpan.class);
+            assertEquals("Wrong number of spans in '" + spannedText + "'", 1, spans.length);
+            assertEquals("Wrong style for '" + spannedText + "' at position " + mStart,
+                    mStyle, spans[0].getStyle());
+        }
+    }
+
+}
diff --git a/tests/src/com/android/quicksearchbox/util/LevenshteinDistanceTest.java b/tests/src/com/android/quicksearchbox/util/LevenshteinDistanceTest.java
new file mode 100644
index 0000000..d1fa700
--- /dev/null
+++ b/tests/src/com/android/quicksearchbox/util/LevenshteinDistanceTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quicksearchbox.util;
+
+import com.android.quicksearchbox.util.LevenshteinDistance.EditOperation;
+
+import android.test.AndroidTestCase;
+
+import java.util.Arrays;
+
+/**
+ * Tests for class {@link LevenshteinDistance}.
+ */
+public class LevenshteinDistanceTest extends AndroidTestCase {
+    // to make the tests less verbose:
+    private static final int INSERT = LevenshteinDistance.EDIT_INSERT;
+    private static final int DELETE = LevenshteinDistance.EDIT_DELETE;
+    private static final int REPLACE = LevenshteinDistance.EDIT_REPLACE;
+    private static final int UNCHANGED = LevenshteinDistance.EDIT_UNCHANGED;
+
+    private void verifyTargetOperations(String[] source, String[] target, int[] expectedOps,
+            int expectedDistance) {
+
+        assertEquals("test error", target.length, expectedOps.length);
+        LevenshteinDistance<String> distance = new LevenshteinDistance<String>
+                (Arrays.asList(source), Arrays.asList(target)){
+                    @Override
+                    protected boolean match(String source, String target) {
+                        return source.equals(target);
+                    }};
+
+        assertEquals(expectedDistance, distance.calculate());
+        EditOperation[] ops = distance.getTargetOperations();
+        assertEquals(expectedOps.length, ops.length);
+        for (int i = 0; i < ops.length; ++i) {
+            assertEquals("Token " + i + " '" + target[i] + "' has wrong operation",
+                    expectedOps[i], ops[i].getType());
+            if (expectedOps[i] == UNCHANGED) {
+                assertEquals(source[ops[i].getPosition()], target[i]);
+            } else if (expectedOps[i] == REPLACE) {
+                assertFalse(source[ops[i].getPosition()].equals(target[i]));
+            }
+        }
+    }
+
+    public void testGetTargetOperationsEmptySource() {
+        verifyTargetOperations(
+                new String[]{},
+                new String[]{},
+                new int[]{},
+                0);
+
+        verifyTargetOperations(
+                new String[]{},
+                new String[]{"goo", "ball"},
+                new int[]{INSERT, INSERT},
+                2);
+    }
+
+    public void testGetTargetOperationsEmptyTarget() {
+        verifyTargetOperations(
+                new String[]{"delete"},
+                new String[]{},
+                new int[]   {},
+                1);
+
+        verifyTargetOperations(
+                new String[]{"delete", "me"},
+                new String[]{},
+                new int[]   {},
+                2);
+    }
+
+    public void testGetTargetOperationsReplacement() {
+        verifyTargetOperations(
+                new String[]{"dennis"},
+                new String[]{"gnasher"},
+                new int[]   {REPLACE},
+                1);
+
+        verifyTargetOperations(
+                new String[]{"angry", "viking"},
+                new String[]{"happy", "kitten"},
+                new int[]   {REPLACE, REPLACE},
+                2);
+    }
+
+    public void testGetTargetOperationsUnchanged() {
+        verifyTargetOperations(
+                new String[]{"tweedledee"},
+                new String[]{"tweedledee"},
+                new int[]   {UNCHANGED},
+                0);
+
+        verifyTargetOperations(
+                new String[]{"tweedledee", "tweedledum"},
+                new String[]{"tweedledee", "tweedledum"},
+                new int[]   {UNCHANGED,     UNCHANGED},
+                0);
+    }
+
+    public void testGetTargetOperationsDuplicateTokens() {
+        String rhubarb = "rhubarb";
+        verifyTargetOperations(
+                new String[]{rhubarb},
+                new String[]{rhubarb,   rhubarb},
+                new int[]   {UNCHANGED, INSERT},
+                1);
+
+        verifyTargetOperations(
+                new String[]{rhubarb,   rhubarb},
+                new String[]{rhubarb,   rhubarb},
+                new int[]   {UNCHANGED, UNCHANGED},
+                0);
+
+        verifyTargetOperations(
+                new String[]{rhubarb,   rhubarb},
+                new String[]{rhubarb,   rhubarb,   rhubarb},
+                new int[]   {UNCHANGED, UNCHANGED, INSERT},
+                1);
+    }
+
+}