[KeepAnno] Add table of contents to user guide

Bug: b/248408342
Change-Id: I88fa8890297e6b416ef4be487b92642e52921508
diff --git a/doc/keepanno-guide.md b/doc/keepanno-guide.md
index 9970ae4..7b16d03 100644
--- a/doc/keepanno-guide.md
+++ b/doc/keepanno-guide.md
@@ -16,8 +16,20 @@
 [R8 component](https://issuetracker.google.com/issues?q=status:open%20componentid:326788).
 
 
+## Table of contents
 
-## Introduction
+- [Introduction](#introduction)
+- [Build configuration](#build-configuration)
+- [Annotating code using reflection](#using-reflection)
+- [Annotating code used by reflection (or via JNI)](#used-by-reflection)
+- [Annotating APIs](#apis)
+- [Migrating rules to annotations](#migrating-rules)
+- [My use case is not covered!](#other-uses)
+- [Troubleshooting](#troubleshooting)
+
+
+
+## Introduction<a id="introduction"></a>
 
 When using a Java/Kotlin shrinker such as R8 or Proguard, developers must inform
 the shrinker about parts of the program that are used either externally from the
@@ -35,7 +47,7 @@
 hopefully more clear and direct meaning.
 
 
-## Build configuration
+## Build configuration<a id="build-configuration"></a>
 
 To use the keep annotations your build must include the library of
 annotations. It is currently built as part of each R8 build and if used with R8,
@@ -61,7 +73,7 @@
   # ... the rest of your R8 compilation command here ...
 ```
 
-### Annotating code using reflection
+## Annotating code using reflection<a id="using-reflection"></a>
 
 The keep annotation library defines a family of annotations depending on your
 use case. You should generally prefer [@UsesReflection](https://storage.googleapis.com/r8-releases/raw/main/docs/keepanno/javadoc/com/android/tools/r8/keepanno/annotations/UsesReflection.html) where applicable.
@@ -96,18 +108,16 @@
 
 
 
-
-### Annotating code used by reflection (or via JNI)
+## Annotating code used by reflection (or via JNI)<a id="used-by-reflection"></a>
 
 
-
-### Annotating APIs
+## Annotating APIs<a id="apis"></a>
 
 
-### Migrating rules to annotations
+## Migrating rules to annotations<a id="migrating-rules"></a>
 
 
-### My use case is not covered!
+## My use case is not covered!<a id="other-uses"></a>
 
 
-### Troubleshooting
+## Troubleshooting<a id="troubleshooting"></a>
diff --git a/doc/keepanno-guide.template.md b/doc/keepanno-guide.template.md
index 6e0a8df..2002802 100644
--- a/doc/keepanno-guide.template.md
+++ b/doc/keepanno-guide.template.md
@@ -13,8 +13,10 @@
 [R8 component](https://issuetracker.google.com/issues?q=status:open%20componentid:326788).
 
 
+[[[TOC]]]
 
-## Introduction
+
+## [Introduction](introduction)
 
 When using a Java/Kotlin shrinker such as R8 or Proguard, developers must inform
 the shrinker about parts of the program that are used either externally from the
@@ -32,7 +34,7 @@
 hopefully more clear and direct meaning.
 
 
-## Build configuration
+## [Build configuration](build-configuration)
 
 To use the keep annotations your build must include the library of
 annotations. It is currently built as part of each R8 build and if used with R8,
@@ -58,7 +60,7 @@
   # ... the rest of your R8 compilation command here ...
 ```
 
-### Annotating code using reflection
+## [Annotating code using reflection](using-reflection)
 
 The keep annotation library defines a family of annotations depending on your
 use case. You should generally prefer `@UsesReflection` where applicable.
@@ -68,18 +70,16 @@
 [[[INCLUDE CODE:UsesReflectionOnVirtualMethod]]]
 
 
-
-### Annotating code used by reflection (or via JNI)
+## [Annotating code used by reflection (or via JNI)](used-by-reflection)
 
 
-
-### Annotating APIs
+## [Annotating APIs](apis)
 
 
-### Migrating rules to annotations
+## [Migrating rules to annotations](migrating-rules)
 
 
-### My use case is not covered!
+## [My use case is not covered!](other-uses)
 
 
-### Troubleshooting
+## [Troubleshooting](troubleshooting)
diff --git a/src/test/java/com/android/tools/r8/keepanno/utils/KeepAnnoMarkdownGenerator.java b/src/test/java/com/android/tools/r8/keepanno/utils/KeepAnnoMarkdownGenerator.java
index f4fd1a8..518d613 100644
--- a/src/test/java/com/android/tools/r8/keepanno/utils/KeepAnnoMarkdownGenerator.java
+++ b/src/test/java/com/android/tools/r8/keepanno/utils/KeepAnnoMarkdownGenerator.java
@@ -4,6 +4,8 @@
 
 package com.android.tools.r8.keepanno.utils;
 
+import static com.android.tools.r8.keepanno.utils.KeepItemAnnotationGenerator.quote;
+
 import com.android.tools.r8.ToolHelper;
 import com.android.tools.r8.keepanno.annotations.KeepBinding;
 import com.android.tools.r8.keepanno.annotations.KeepCondition;
@@ -22,9 +24,15 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 public class KeepAnnoMarkdownGenerator {
 
@@ -39,6 +47,8 @@
   private static String JAVADOC_URL =
       "https://storage.googleapis.com/r8-releases/raw/main/docs/keepanno/javadoc/";
 
+  private static final String TOC_MARKER = "[[[TOC]]]";
+
   private static final String INCLUDE_MD_START = "[[[INCLUDE";
   private static final String INCLUDE_MD_DOC_START = "[[[INCLUDE DOC";
   private static final String INCLUDE_MD_CODE_START = "[[[INCLUDE CODE";
@@ -135,10 +145,17 @@
     println("[comment]: <> (Changes should be made in " + template + ")");
     println();
     List<String> readAllLines = FileUtils.readAllLines(template);
+    TableEntry root = new TableEntry(0, "root", "root", null);
+    readAllLines = collectTableOfContents(readAllLines, root);
+
     for (int i = 0; i < readAllLines.size(); i++) {
       String line = readAllLines.get(i);
       try {
-        processLine(line, generator);
+        if (line.trim().equals(TOC_MARKER)) {
+          printTableOfContents(root);
+        } else {
+          processLine(line, generator);
+        }
       } catch (Exception e) {
         System.err.println("Parse error on line " + (i + 1) + ":");
         System.err.println(line);
@@ -147,6 +164,70 @@
     }
   }
 
+  private void printTableOfContents(TableEntry root) {
+    println("## Table of contents");
+    println();
+    printTableSubEntries(root.subSections.values());
+    println();
+  }
+
+  private void printTableSubEntries(Collection<TableEntry> entries) {
+    for (TableEntry entry : entries) {
+      println("- " + entry.getHrefLink());
+      generator.withIndent(() -> printTableSubEntries(entry.subSections.values()));
+    }
+  }
+
+  private List<String> collectTableOfContents(List<String> lines, TableEntry root) {
+    Set<String> seen = new HashSet<>();
+    TableEntry current = root;
+    List<String> newLines = new ArrayList<>(lines.size());
+    Iterator<String> iterator = lines.iterator();
+    // Skip forward until the TOC insertion.
+    while (iterator.hasNext()) {
+      String line = iterator.next();
+      newLines.add(line);
+      if (line.trim().equals(TOC_MARKER)) {
+        break;
+      }
+    }
+    // Find TOC entries and replace the headings with links.
+    while (iterator.hasNext()) {
+      String line = iterator.next();
+      int headingDepth = 0;
+      for (int i = 0; i < line.length(); i++) {
+        char c = line.charAt(i);
+        if (c != '#') {
+          headingDepth = i;
+          break;
+        }
+      }
+      if (headingDepth == 0) {
+        newLines.add(line);
+        continue;
+      }
+      String headingPrefix = line.substring(0, headingDepth);
+      String headingContent = line.substring(headingDepth).trim();
+      int splitIndex = headingContent.indexOf("](");
+      if (splitIndex < 0 || !headingContent.startsWith("[") || !headingContent.endsWith(")")) {
+        throw new RuntimeException("Invalid heading format. Use [Heading Text](heading-id)");
+      }
+      String headingText = headingContent.substring(1, splitIndex);
+      String headingId = headingContent.substring(splitIndex + 2, headingContent.length() - 1);
+      if (!seen.add(headingId)) {
+        throw new RuntimeException("Duplicate heading id: " + headingText);
+      }
+      while (headingDepth <= current.depth) {
+        current = current.parent;
+      }
+      TableEntry entry = new TableEntry(headingDepth, headingText, headingId, current);
+      current.subSections.put(headingText, entry);
+      current = entry;
+      newLines.add(headingPrefix + " " + entry.getIdAnchor());
+    }
+    return newLines;
+  }
+
   private String replaceCodeAndDocMarkers(String line) {
     String originalLine = line;
     line = line.trim();
@@ -190,25 +271,46 @@
   private StringBuilder unindentLines(String replacement, StringBuilder builder) {
     int shortestSpacePrefix = Integer.MAX_VALUE;
     List<String> lines = StringUtils.split(replacement, '\n');
+    lines = trimEmptyLines(lines);
     for (String line : lines) {
       if (!line.isEmpty()) {
         shortestSpacePrefix = Math.min(shortestSpacePrefix, findFirstNonSpaceIndex(line));
       }
     }
-    if (shortestSpacePrefix > 0) {
-      for (String line : lines) {
-        if (!line.isEmpty()) {
-          builder.append(line.substring(shortestSpacePrefix));
-        }
-        builder.append('\n');
+    for (String line : lines) {
+      if (!line.isEmpty()) {
+        builder.append(line.substring(shortestSpacePrefix));
       }
-    } else {
-      builder.append(replacement);
       builder.append('\n');
     }
     return builder;
   }
 
+  private static List<String> trimEmptyLines(List<String> lines) {
+    int startLineIndex = 0;
+    int endLineIndex = lines.size() - 1;
+    while (true) {
+      String line = lines.get(startLineIndex);
+      if (line.trim().isEmpty()) {
+        startLineIndex++;
+      } else {
+        break;
+      }
+    }
+    while (true) {
+      String line = lines.get(endLineIndex);
+      if (line.trim().isEmpty()) {
+        --endLineIndex;
+      } else {
+        break;
+      }
+    }
+    if (startLineIndex != 0 || endLineIndex != lines.size() - 1) {
+      lines = lines.subList(startLineIndex, endLineIndex + 1);
+    }
+    return lines;
+  }
+
   private int findFirstNonSpaceIndex(String line) {
     for (int i = 0; i < line.length(); i++) {
       if (line.charAt(i) != ' ') {
@@ -244,4 +346,27 @@
     }
     generator.println(line);
   }
+
+  private static class TableEntry {
+    final int depth;
+    final String name;
+    final String id;
+    final TableEntry parent;
+    final Map<String, TableEntry> subSections = new LinkedHashMap<>();
+
+    public TableEntry(int depth, String name, String id, TableEntry parent) {
+      this.depth = depth;
+      this.name = name;
+      this.id = id;
+      this.parent = parent;
+    }
+
+    public String getHrefLink() {
+      return "[" + name + "](#" + id + ")";
+    }
+
+    public String getIdAnchor() {
+      return name + "<a id=" + quote(id) + "></a>";
+    }
+  }
 }
diff --git a/src/test/java/com/android/tools/r8/keepanno/utils/KeepItemAnnotationGenerator.java b/src/test/java/com/android/tools/r8/keepanno/utils/KeepItemAnnotationGenerator.java
index 005c344..d139b43 100644
--- a/src/test/java/com/android/tools/r8/keepanno/utils/KeepItemAnnotationGenerator.java
+++ b/src/test/java/com/android/tools/r8/keepanno/utils/KeepItemAnnotationGenerator.java
@@ -47,7 +47,7 @@
     Generator.run();
   }
 
-  private static String quote(String str) {
+  public static String quote(String str) {
     return "\"" + str + "\"";
   }
 
@@ -218,7 +218,7 @@
       this.writer = writer;
     }
 
-    private void withIndent(Runnable fn) {
+    public void withIndent(Runnable fn) {
       indent += 2;
       fn.run();
       indent -= 2;