Merge "Linkify commit messages using regexp-based rules"
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/CommentLinkInfo.java b/gitiles-servlet/src/main/java/com/google/gitiles/CommentLinkInfo.java
new file mode 100644
index 0000000..536d149
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/CommentLinkInfo.java
@@ -0,0 +1,90 @@
+// Copyright 2014 Google Inc. All Rights Reserved.
+//
+// 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.google.gitiles;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Converts commit message text to soy data in accordance with
+ * a commentlink rule.
+ * <p>
+ * Example:
+ * <pre>
+ *  new CommentLinkInfo(
+ *      Pattern.compile("bug \d+"),
+ *      "http://bugs/$1")
+ *    .linkify("do something nice\n\nbug 5")
+ * </pre>
+ * <p>
+ * returns a list of soy data objects:
+ * <pre>
+ * ImmutableList.of(
+ *   ImmutableMap.of("text", "do something nice\n\n"),
+ *   ImmutableMap.of("text", "bug 5", "url", "http://bugs/5")
+ * )
+ * </pre>
+ */
+public class CommentLinkInfo {
+  private final Pattern pattern;
+  private final String link;
+
+  public CommentLinkInfo(Pattern pattern, String link) {
+    this.pattern = checkNotNull(pattern);
+    this.link = checkNotNull(link);
+  }
+
+  public List<Map<String, String>> linkify(String input) {
+    List<Map<String, String>> parsed = Lists.newArrayList();
+    Matcher m = pattern.matcher(input);
+    int last = 0;
+    while (m.find()) {
+      addText(parsed, input.substring(last, m.start()));
+      String text = m.group(0);
+      addLink(parsed, text, pattern.matcher(text).replaceAll(link));
+      last = m.end();
+    }
+    addText(parsed, input.substring(last));
+    return ImmutableList.copyOf(parsed);
+  }
+
+  private static void addLink(List<Map<String, String>> parts, String text, String url) {
+    parts.add(ImmutableMap.of("text", text, "url", url));
+  }
+
+  private static void addText(List<Map<String, String>> parts, String text) {
+    if (text.isEmpty()) {
+      return;
+    }
+    if (parts.isEmpty()) {
+      parts.add(ImmutableMap.of("text", text));
+    } else {
+      Map<String, String> old = parts.get(parts.size() - 1);
+      if (!old.containsKey("url")) {
+        parts.set(parts.size() - 1, ImmutableMap.of("text", old.get("text") + text));
+      } else {
+        parts.add(ImmutableMap.of("text", text));
+      }
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
index 3d548b0..b38c9c6 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -296,7 +296,7 @@
   private synchronized Linkifier linkifier() {
     if (linkifier == null) {
       checkState(urls != null, "GitilesUrls not yet set");
-      linkifier = new Linkifier(urls);
+      linkifier = new Linkifier(urls, config);
     }
     return linkifier;
   }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
index a9673b4..1a49a41 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
@@ -17,11 +17,19 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -29,7 +37,11 @@
 
 /** Linkifier for blocks of text such as commit message descriptions. */
 public class Linkifier {
-  private static final Pattern LINK_PATTERN;
+  private static final Logger log = LoggerFactory.getLogger(Linkifier.class);
+
+  private static final String COMMENTLINK = "commentlink";
+  private static final Pattern HTTP_URL_PATTERN;
+  private static final Pattern CHANGE_ID_PATTERN;
 
   static {
     // HTTP URL regex adapted from com.google.gwtexpui.safehtml.client.SafeHtml.
@@ -41,59 +53,85 @@
         part + "{2,}" +
         "(?:[(]" + part + "*" + "[)])*" +
         part + "*";
-    String changeId = "\\bI[0-9a-f]{8,40}\\b";
-    LINK_PATTERN = Pattern.compile(Joiner.on("|").join(
-        "(" + httpUrl + ")",
-        "(" + changeId + ")"));
+    HTTP_URL_PATTERN = Pattern.compile(httpUrl);
+    CHANGE_ID_PATTERN = Pattern.compile("(\\bI[0-9a-f]{8,40}\\b)");
   }
 
   private final GitilesUrls urls;
+  private final List<CommentLinkInfo> commentLinks;
+  private final Pattern allPattern;
 
-  public Linkifier(GitilesUrls urls) {
+  public Linkifier(GitilesUrls urls, Config config) {
     this.urls = checkNotNull(urls, "urls");
+
+    List<CommentLinkInfo> list = new ArrayList<>();
+    list.add(new CommentLinkInfo(HTTP_URL_PATTERN, "$0"));
+    Set<String> subsections = config.getSubsections(COMMENTLINK);
+
+    List<String> patterns = new ArrayList<>();
+
+    patterns.add(HTTP_URL_PATTERN.pattern());
+    patterns.add(CHANGE_ID_PATTERN.pattern());
+
+    for (String subsection : subsections) {
+      String match = config.getString("commentlink", subsection, "match");
+      String link = config.getString("commentlink", subsection, "link");
+      String html = config.getString("commentlink", subsection, "html");
+      if (html != null) {
+        log.warn("Beware: html in commentlinks is unsupported in gitiles; "
+               + "Did you copy it from a gerrit config?");
+      }
+      if (Strings.isNullOrEmpty(match)) {
+        log.warn("invalid commentlink.%s.match", subsection);
+        continue;
+      }
+      if (Strings.isNullOrEmpty(link)) {
+        log.warn("invalid commentlink.%s.link", subsection);
+        continue;
+      }
+      Pattern pattern = Pattern.compile(match);
+      list.add(new CommentLinkInfo(pattern, link));
+      patterns.add(match);
+    }
+    this.commentLinks = Collections.unmodifiableList(list);
+    allPattern = Pattern.compile(Joiner.on('|').join(patterns));
   }
 
   public List<Map<String, String>> linkify(HttpServletRequest req, String message) {
+
+    List<CommentLinkInfo> operationalCommentLinks = new ArrayList<>(commentLinks);
+    // Because we're relying on 'req' as a dynamic parameter, we need to construct
+    // the CommentLinkInfo for ChangeIds on the fly.
     String baseGerritUrl = urls.getBaseGerritUrl(req);
+
+    if (baseGerritUrl != null) {
+      CommentLinkInfo changeIds =
+          new CommentLinkInfo(CHANGE_ID_PATTERN, baseGerritUrl + "#/q/$0,n,z");
+      operationalCommentLinks.add(changeIds);
+    }
+
     List<Map<String, String>> parsed = Lists.newArrayList();
-    Matcher m = LINK_PATTERN.matcher(message);
-    int last = 0;
-    while (m.find()) {
-      addText(parsed, message.substring(last, m.start()));
-      if (m.group(1) != null) {
-        // Bare URL.
-        parsed.add(link(m.group(1), m.group(1)));
-      } else if (m.group(2) != null) {
-        if (baseGerritUrl != null) {
-          // Gerrit Change-Id.
-          parsed.add(link(m.group(2), baseGerritUrl + "#/q/" + m.group(2) + ",n,z"));
-        } else {
-          addText(parsed, m.group(2));
+    parsed.add(ImmutableMap.of("text", message));
+
+    for (int index = 0; index < parsed.size(); index++) {
+      if (parsed.get(index).get("url") != null) {
+        continue;
+      }
+      Matcher m = allPattern.matcher(parsed.get(index).get("text"));
+      if (!m.find()) {
+        continue;
+      }
+
+      for (CommentLinkInfo cli : operationalCommentLinks) {
+        // No need to apply more rules if this is already a link.
+        if (parsed.get(index).get("url") != null) {
+          break;
         }
+        String text = parsed.get(index).get("text");
+        parsed.remove(index);
+        parsed.addAll(index, cli.linkify(text));
       }
-      last = m.end();
     }
-    addText(parsed, message.substring(last));
     return parsed;
   }
-
-  private static Map<String, String> link(String text, String url) {
-    return ImmutableMap.of("text", text, "url", url);
-  }
-
-  private static void addText(List<Map<String, String>> parts, String text) {
-    if (text.isEmpty()) {
-      return;
-    }
-    if (parts.isEmpty()) {
-      parts.add(ImmutableMap.of("text", text));
-    } else {
-      Map<String, String> old = parts.get(parts.size() - 1);
-      if (!old.containsKey("url")) {
-        parts.set(parts.size() - 1, ImmutableMap.of("text", old.get("text") + text));
-      } else {
-        parts.add(ImmutableMap.of("text", text));
-      }
-    }
-  }
 }
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java
index fa70bc6..9a04f9d 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/LinkifierTest.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 
+import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -32,14 +33,16 @@
 
   @Test
   public void linkifyMessageNoMatch() throws Exception {
-    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    Config config = new Config();
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS, config);
     assertEquals(ImmutableList.of(ImmutableMap.of("text", "some message text")),
         l.linkify(FakeHttpServletRequest.newRequest(), "some message text"));
   }
 
   @Test
   public void linkifyMessageUrl() throws Exception {
-    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    Config config = new Config();
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS, config);
     assertEquals(ImmutableList.of(
         ImmutableMap.of("text", "http://my/url", "url", "http://my/url")),
         l.linkify(REQ, "http://my/url"));
@@ -62,6 +65,7 @@
 
   @Test
   public void linkifyMessageChangeIdNoGerrit() throws Exception {
+    Config config = new Config();
     Linkifier l = new Linkifier(new GitilesUrls() {
       @Override
       public String getBaseGerritUrl(HttpServletRequest req) {
@@ -77,7 +81,7 @@
       public String getBaseGitUrl(HttpServletRequest req) {
         throw new UnsupportedOperationException();
       }
-    });
+    }, config);
     assertEquals(ImmutableList.of(ImmutableMap.of("text", "I0123456789")),
         l.linkify(REQ, "I0123456789"));
     assertEquals(ImmutableList.of(ImmutableMap.of("text", "Change-Id: I0123456789")),
@@ -88,7 +92,8 @@
 
   @Test
   public void linkifyMessageChangeId() throws Exception {
-    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    Config config = new Config();
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS, config);
     assertEquals(ImmutableList.of(
         ImmutableMap.of("text", "I0123456789",
           "url", "http://test-host-review/foo/#/q/I0123456789,n,z")),
@@ -107,8 +112,25 @@
   }
 
   @Test
+  public void linkifyMessageCommentLinks() throws Exception {
+    Config config = new Config();
+    config.setString("commentlink", "buglink", "match", "(bug\\s+#?)(\\d+)");
+    config.setString("commentlink", "buglink", "link", "http://bugs/$2");
+    config.setString("commentlink", "featurelink", "match", "(Feature:\\s+)(\\d+)");
+    config.setString("commentlink", "featurelink", "link", "http://features/$2");
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS, config);
+    assertEquals(ImmutableList.of(
+        ImmutableMap.of("text", "There is a new "),
+        ImmutableMap.of("text", "Feature: 103", "url", "http://features/103"),
+        ImmutableMap.of("text", ", which is similar to the reported "),
+        ImmutableMap.of("text", "bug 100", "url", "http://bugs/100")),
+        l.linkify(REQ, "There is a new Feature: 103, which is similar to the reported bug 100"));
+  }
+
+  @Test
   public void linkifyMessageUrlAndChangeId() throws Exception {
-    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    Config config = new Config();
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS, config);
     assertEquals(ImmutableList.of(
         ImmutableMap.of("text", "http://my/url/I0123456789", "url", "http://my/url/I0123456789"),
         ImmutableMap.of("text", " is not change "),
@@ -119,7 +141,8 @@
 
   @Test
   public void linkifyAmpersand() throws Exception {
-    Linkifier l = new Linkifier(TestGitilesUrls.URLS);
+    Config config = new Config();
+    Linkifier l = new Linkifier(TestGitilesUrls.URLS, config);
     assertEquals(ImmutableList.of(
         ImmutableMap.of("text", "http://my/url?a&b", "url", "http://my/url?a&b")),
         l.linkify(REQ, "http://my/url?a&b"));