Markdown: apply prettify to verbatim blocks

Verbatim blocks may optionally specify a language, e.g.:

  ```java
  import java.io.InputStream;
  ```

If the named language is known to be supported by prettify parser
try to format with syntax highlighting and include that styled
result in the HTML.

Avoid recreating the Prettify instance each time a block needs to
be formatted. Prettify can be run over multiple inputs but is not
thread-safe due to updates of the file extension map while loading a
language that has not been processed before.

Replacing the map with a synchronized map during init will prevent
internal map corruption when concurrent threads initialize languages.

Change-Id: Ia37628d7c440df0170a21e69383ab69b20e85beb
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
index 72fde6d..87fc5c9 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
@@ -34,14 +34,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import prettify.PrettifyParser;
-import prettify.parser.Prettify;
-import syntaxhighlight.ParseResult;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 
+import prettify.parser.Prettify;
+import syntaxhighlight.ParseResult;
+
 /** Soy data converter for git blobs. */
 public class BlobSoyData {
   private static final Logger log = LoggerFactory.getLogger(BlobSoyData.class);
@@ -121,8 +120,9 @@
   }
 
   private List<ParseResult> parse(String path, String content) {
+    String lang = extension(path, content);
     try {
-      return new PrettifyParser().parse(extension(path, content), content);
+      return ThreadSafePrettifyParser.INSTANCE.parse(lang, content);
     } catch (StackOverflowError e) {
       // TODO(dborowitz): Aaagh. Make prettify use RE2. Or replace it something
       // else. Or something.
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ThreadSafePrettifyParser.java b/gitiles-servlet/src/main/java/com/google/gitiles/ThreadSafePrettifyParser.java
new file mode 100644
index 0000000..f5881d4
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ThreadSafePrettifyParser.java
@@ -0,0 +1,34 @@
+// Copyright 2015 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 java.util.Collections;
+
+import prettify.PrettifyParser;
+import prettify.parser.Prettify;
+
+public class ThreadSafePrettifyParser extends PrettifyParser {
+  public static final ThreadSafePrettifyParser INSTANCE =
+      new ThreadSafePrettifyParser();
+
+  private ThreadSafePrettifyParser() {
+    // Prettify is not thread safe ... unless we do this.
+    prettify = new Prettify() {
+      {
+        langHandlerRegistry = Collections.synchronizedMap(langHandlerRegistry);
+      }
+    };
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
index d189c2d..33cbdfa 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -17,7 +17,9 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gitiles.doc.MarkdownUtil.getInnerText;
 
+import com.google.common.base.Strings;
 import com.google.gitiles.GitilesView;
+import com.google.gitiles.ThreadSafePrettifyParser;
 import com.google.gitiles.doc.html.HtmlBuilder;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri;
@@ -63,6 +65,11 @@
 import org.pegdown.ast.VerbatimNode;
 import org.pegdown.ast.WikiLinkNode;
 
+import java.util.List;
+
+import prettify.parser.Prettify;
+import syntaxhighlight.ParseResult;
+
 /**
  * Formats parsed markdown AST into HTML.
  * <p>
@@ -235,16 +242,59 @@
 
   @Override
   public void visit(VerbatimNode node) {
-    html.open("pre").attribute("class", "code");
+    String lang = node.getType();
     String text = node.getText();
-    while (text.startsWith("\n")) {
-      html.open("br");
-      text = text.substring(1);
+
+    html.open("pre").attribute("class", "code");
+    text = printLeadingBlankLines(text);
+    List<ParseResult> parsed = parse(lang, text);
+    if (parsed != null) {
+      int last = 0;
+      for (ParseResult r : parsed) {
+        span(null, text, last, r.getOffset());
+        last = r.getOffset() + r.getLength();
+        span(r.getStyleKeysString(), text, r.getOffset(), last);
+      }
+      if (last < text.length()) {
+        span(null, text, last, text.length());
+      }
+    } else {
+      html.appendAndEscape(text);
     }
-    html.appendAndEscape(text);
     html.close("pre");
   }
 
+  private String printLeadingBlankLines(String text) {
+    int i = 0;
+    while (i < text.length() && text.charAt(i) == '\n') {
+      html.open("br");
+      i++;
+    }
+    return text.substring(i);
+  }
+
+  private void span(String classes, String s, int start, int end) {
+    if (end - start > 0) {
+      if (Strings.isNullOrEmpty(classes)) {
+        classes = Prettify.PR_PLAIN;
+      }
+      html.open("span").attribute("class", classes);
+      html.appendAndEscape(s.substring(start, end));
+      html.close("span");
+    }
+  }
+
+  private List<ParseResult> parse(String lang, String text) {
+    if (Strings.isNullOrEmpty(lang)) {
+      return null;
+    }
+    try {
+      return ThreadSafePrettifyParser.INSTANCE.parse(lang, text);
+    } catch (StackOverflowError e) {
+      return null;
+    }
+  }
+
   @Override
   public void visit(CodeNode node) {
     wrapText("code", node);
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java
index 85e62c5..5b74c35 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/html/HtmlBuilder.java
@@ -47,7 +47,7 @@
       "ol", "ul", "li", "dl", "dd", "dt",
       "del", "em", "strong", "code", "br", "hr",
       "table", "thead", "tbody", "caption", "tr", "th", "td",
-      "iframe"
+      "iframe", "span"
   );
 
   private static final ImmutableSet<String> ALLOWED_ATTRIBUTES = ImmutableSet.of(
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
index da475c6..b0daab3 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
@@ -36,6 +36,7 @@
     {$pageTitle}
   </title>
   <link rel="stylesheet" type="text/css" href="{gitiles.DOC_CSS_URL}" />
+  <link rel="stylesheet" type="text/css" href="{gitiles.PRETTIFY_CSS_URL}" />
 </head>
 <body>
   {if $siteTitle}