Add utility for parsing kotlin generated source debug extensions

Bug: 141817471
Change-Id: I6043427b43f8d1a475eed0d02b6377043213fb87
diff --git a/src/main/java/com/android/tools/r8/kotlin/KotlinSourceDebugExtensionParser.java b/src/main/java/com/android/tools/r8/kotlin/KotlinSourceDebugExtensionParser.java
new file mode 100644
index 0000000..fcf1453
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/kotlin/KotlinSourceDebugExtensionParser.java
@@ -0,0 +1,332 @@
+// Copyright (c) 2019, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.kotlin;
+
+import com.android.tools.r8.naming.Range;
+import com.android.tools.r8.utils.ThrowingConsumer;
+import java.io.BufferedReader;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+// This is a parser for the Kotlin-generated source debug extensions, which is a stratified map.
+// The kotlin-parser for this data is can be found here in the kotlin compiler:
+// compiler/backend/src/org/jetbrains/kotlin/codegen/inline/SMAPParser.kt
+public class KotlinSourceDebugExtensionParser {
+
+  private static final String SMAP_IDENTIFIER = "SMAP";
+  private static final String SMAP_SECTION_START = "*S";
+  private static final String SMAP_SECTION_KOTLIN_START = SMAP_SECTION_START + " Kotlin";
+  private static final String SMAP_FILES_IDENTIFIER = "*F";
+  private static final String SMAP_LINES_IDENTIFIER = "*L";
+  private static final String SMAP_END_IDENTIFIER = "*E";
+
+  public static class KotlinSourceDebugExtensionParserException extends Exception {
+
+    KotlinSourceDebugExtensionParserException(String message) {
+      super(message);
+    }
+  }
+
+  public static class BufferedStringReader implements Closeable {
+
+    private final BufferedReader reader;
+
+    private String readLine;
+
+    BufferedStringReader(String data) {
+      reader = new BufferedReader(new StringReader(data));
+    }
+
+    String readNextLine() throws IOException {
+      return readLine = reader.readLine();
+    }
+
+    boolean readExpectedLine(String expected) throws IOException {
+      return readNextLine().equals(expected);
+    }
+
+    void readExpectedLineOrThrow(String expected)
+        throws KotlinSourceDebugExtensionParserException, IOException {
+      if (!readExpectedLine(expected)) {
+        throw new KotlinSourceDebugExtensionParserException(
+            "The string " + readLine + " does not match the expected string " + expected);
+      }
+    }
+
+    boolean isEOF() {
+      return readLine == null;
+    }
+
+    BufferedStringReader readUntil(String terminator) throws IOException {
+      while (!terminator.equals(readLine) && !isEOF()) {
+        readNextLine();
+      }
+      if (isEOF()) {
+        return this;
+      }
+      return this;
+    }
+
+    void readUntil(
+        String terminator,
+        int linesInBlock,
+        ThrowingConsumer<List<String>, KotlinSourceDebugExtensionParserException> callback)
+        throws IOException, KotlinSourceDebugExtensionParserException {
+      if (terminator.equals(readLine)) {
+        return;
+      }
+      List<String> readStrings = new ArrayList<>();
+      readStrings.add(readNextLine());
+      int linesLeft = linesInBlock;
+      while (!terminator.equals(readLine) && !isEOF()) {
+        if (linesLeft == 1) {
+          assert readStrings.size() == linesInBlock;
+          callback.accept(readStrings);
+          linesLeft = linesInBlock;
+          readStrings = new ArrayList<>();
+        } else {
+          linesLeft -= 1;
+        }
+        readStrings.add(readNextLine());
+      }
+      if (readStrings.size() > 0 && !readStrings.get(0).equals(terminator)) {
+        throw new KotlinSourceDebugExtensionParserException(
+            "Block size does not match linesInBlock = " + linesInBlock);
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      reader.close();
+    }
+  }
+
+  public static Result parse(String annotationData) {
+    if (annotationData == null || annotationData.isEmpty()) {
+      return null;
+    }
+
+    // The source debug smap data is on the following form:
+    // SMAP
+    // <filename>
+    // Kotlin <-- why is this even here?
+    // *S <section>
+    // *F
+    // + <file_id_i> <file_name_i>
+    // <path_i>
+    // *L
+    // <from>#<file>,<to>:<debug-line-position>
+    // <from>#<file>:<debug-line-position>
+    // *E
+    // *S KotlinDebug
+    // ***
+    // *E
+    try (BufferedStringReader reader = new BufferedStringReader(annotationData)) {
+      ResultBuilder builder = new ResultBuilder();
+      // Check for SMAP
+      if (!reader.readExpectedLine(SMAP_IDENTIFIER)) {
+        return null;
+      }
+      if (reader.readUntil(SMAP_SECTION_KOTLIN_START).isEOF()) {
+        return null;
+      }
+      // At this point we should be parsing a kotlin source debug extension, so we will throw if we
+      // read an unexpected line.
+      reader.readExpectedLineOrThrow(SMAP_FILES_IDENTIFIER);
+      // Iterate over the files section with the format:
+      // + <file_number_i> <file_name_i>
+      // <file_path_i>
+      reader.readUntil(
+          SMAP_LINES_IDENTIFIER, 2, block -> addFileToBuilder(block.get(0), block.get(1), builder));
+
+      // Ensure that we read *L.
+      if (reader.isEOF()) {
+        throw new KotlinSourceDebugExtensionParserException(
+            "Unexpected EOF - no debug line positions");
+      }
+      // Iterate over the debug line number positions:
+      // <from>#<file>,<to>:<debug-line-position>
+      // or
+      // <from>#<file>:<debug-line-position>
+      reader.readUntil(
+          SMAP_END_IDENTIFIER, 1, block -> addDebugEntryToBuilder(block.get(0), builder));
+
+      // Ensure that we read the end section *E.
+      if (reader.isEOF()) {
+        throw new KotlinSourceDebugExtensionParserException(
+            "Unexpected EOF when parsing SMAP debug entries");
+      }
+
+      return builder.build();
+    } catch (IOException | KotlinSourceDebugExtensionParserException e) {
+      return null;
+    }
+  }
+
+  private static void addFileToBuilder(String entryLine, String filePath, ResultBuilder builder)
+      throws KotlinSourceDebugExtensionParserException {
+    // + <file_number_i> <file_name_i>
+    // <file_path_i>
+    String[] entries = entryLine.trim().split(" ");
+    if (entries.length != 3 || !entries[0].equals("+")) {
+      throw new KotlinSourceDebugExtensionParserException(
+          "Wrong number of entries on line " + entryLine);
+    }
+    String fileName = entries[2];
+    if (fileName.isEmpty()) {
+      throw new KotlinSourceDebugExtensionParserException(
+          "Did not expect file name to be empty for line " + entryLine);
+    }
+    if (filePath == null || filePath.isEmpty()) {
+      throw new KotlinSourceDebugExtensionParserException(
+          "Did not expect file path to be null or empty for " + entryLine);
+    }
+    int index = asInteger(entries[1]);
+    Source source = new Source(fileName, filePath);
+    Source existingSource = builder.files.put(index, source);
+    if (existingSource != null) {
+      throw new KotlinSourceDebugExtensionParserException(
+          "File index " + index + " was already mapped to an existing source: " + source);
+    }
+  }
+
+  private static int asInteger(String numberAsString)
+      throws KotlinSourceDebugExtensionParserException {
+    int number = -1;
+    try {
+      number = Integer.parseInt(numberAsString);
+    } catch (NumberFormatException e) {
+      throw new KotlinSourceDebugExtensionParserException(
+          "Could not parse number " + numberAsString);
+    }
+    return number;
+  }
+
+  private static void addDebugEntryToBuilder(String debugEntry, ResultBuilder builder)
+      throws KotlinSourceDebugExtensionParserException {
+    // <from>#<file>,<to>:<debug-line-position>
+    // or
+    // <from>#<file>:<debug-line-position>
+    try {
+      int targetSplit = debugEntry.indexOf(':');
+      int target = Integer.parseInt(debugEntry.substring(targetSplit + 1));
+      String original = debugEntry.substring(0, targetSplit);
+      int fileIndexSplit = original.indexOf('#');
+      int originalStart = Integer.parseInt(original.substring(0, fileIndexSplit));
+      // The range may have a different end than start.
+      String fileAndEndRange = original.substring(fileIndexSplit + 1);
+      int endRangeCharPosition = fileAndEndRange.indexOf(',');
+      int originalEnd = originalStart;
+      if (endRangeCharPosition > -1) {
+        // The file should be at least one number wide.
+        assert endRangeCharPosition > 0;
+        originalEnd = Integer.parseInt(fileAndEndRange.substring(endRangeCharPosition + 1));
+      } else {
+        endRangeCharPosition = fileAndEndRange.length();
+      }
+      int fileIndex = Integer.parseInt(fileAndEndRange.substring(0, endRangeCharPosition));
+      Source thisFileSource = builder.files.get(fileIndex);
+      if (thisFileSource != null) {
+        Range range = new Range(originalStart, originalEnd);
+        Position position = new Position(thisFileSource, range);
+        Position existingPosition = builder.positions.put(target, position);
+        assert existingPosition == null
+            : "Position index "
+                + target
+                + " was already mapped to an existing position: "
+                + position;
+      }
+    } catch (NumberFormatException e) {
+      throw new KotlinSourceDebugExtensionParserException("Could not convert position to number");
+    }
+  }
+
+  public static class Result {
+
+    private final Map<Integer, Source> files;
+    private final Map<Integer, Position> positions;
+
+    private Result(Map<Integer, Source> files, Map<Integer, Position> positions) {
+      this.files = files;
+      this.positions = positions;
+    }
+
+    public Map<Integer, Source> getFiles() {
+      return files;
+    }
+
+    public Map<Integer, Position> getPositions() {
+      return positions;
+    }
+  }
+
+  public static class ResultBuilder {
+    final Map<Integer, Source> files = new HashMap<>();
+    final Map<Integer, Position> positions = new HashMap<>();
+
+    public Result build() {
+      return new Result(files, positions);
+    }
+  }
+
+  public static class Source {
+    private final String fileName;
+    private final String path;
+
+    private Source(String fileName, String path) {
+      this.fileName = fileName;
+      this.path = path;
+    }
+
+    public String getFileName() {
+      return fileName;
+    }
+
+    public String getPath() {
+      return path;
+    }
+
+    @Override
+    public String toString() {
+      return path + "(" + fileName + ")";
+    }
+  }
+
+  public static class Position {
+    private final Source source;
+    private final Range range;
+
+    public Position(Source source, Range range) {
+      this.source = source;
+      this.range = range;
+    }
+
+    public Source getSource() {
+      return source;
+    }
+
+    public Range getRange() {
+      return range;
+    }
+
+    @Override
+    public String toString() {
+      StringBuilder sb = new StringBuilder();
+      sb.append(range.from);
+      sb.append("#");
+      sb.append(source);
+      if (range.to != range.from) {
+        sb.append(",");
+        sb.append(range.to);
+      }
+      return sb.toString();
+    }
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/kotlin/KotlinSourceDebugExtensionParserTest.java b/src/test/java/com/android/tools/r8/kotlin/KotlinSourceDebugExtensionParserTest.java
new file mode 100644
index 0000000..b85f583
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/kotlin/KotlinSourceDebugExtensionParserTest.java
@@ -0,0 +1,294 @@
+// Copyright (c) 2019, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.kotlin;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.kotlin.KotlinSourceDebugExtensionParser.Position;
+import com.android.tools.r8.kotlin.KotlinSourceDebugExtensionParser.Result;
+import com.android.tools.r8.kotlin.KotlinSourceDebugExtensionParser.Source;
+import com.android.tools.r8.utils.StringUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class KotlinSourceDebugExtensionParserTest extends TestBase {
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withNoneRuntime().build();
+  }
+
+  public KotlinSourceDebugExtensionParserTest(TestParameters parameters) {}
+
+  @Test
+  public void testParsingEmpty() {
+    assertNull(KotlinSourceDebugExtensionParser.parse(null));
+  }
+
+  @Test
+  public void testParsingNoInlineSources() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "EnumSwitch.kt",
+            "Kotlin",
+            "*S Kotlin",
+            "*F",
+            "+ 1 EnumSwitch.kt",
+            "enumswitch/EnumSwitchKt",
+            "*L",
+            "1#1,38:1",
+            "*E");
+    Result result = KotlinSourceDebugExtensionParser.parse(annotationData);
+    assertNotNull(result);
+    assertEquals(1, result.getFiles().size());
+    Source source = result.getFiles().get(1);
+    assertEquals("EnumSwitch.kt", source.getFileName());
+    assertEquals("enumswitch/EnumSwitchKt", source.getPath());
+    assertTrue(result.getPositions().containsKey(1));
+    Position position = result.getPositions().get(1);
+    assertEquals(source, position.getSource());
+    assertEquals(1, position.getRange().from);
+    assertEquals(38, position.getRange().to);
+  }
+
+  @Test
+  public void testParsingSimpleStrata() {
+    // Taken from src/test/examplesKotlin/retrace/mainKt
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*S Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 3 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*L",
+            "1#1,22:1",
+            "7#2:23",
+            "12#3:24",
+            "*E",
+            "*S KotlinDebug",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "*L",
+            "12#1:23",
+            "18#1:24",
+            "*E");
+    Result result = KotlinSourceDebugExtensionParser.parse(annotationData);
+    assertNotNull(result);
+    assertEquals(3, result.getFiles().size());
+    // Check that files are correctly parsed.
+    Source source1 = result.getFiles().get(1);
+    assertEquals("Main.kt", source1.getFileName());
+    assertEquals("retrace/MainKt", source1.getPath());
+
+    Source source2 = result.getFiles().get(2);
+    assertEquals("InlineFunction.kt", source2.getFileName());
+    assertEquals("retrace/InlineFunctionKt", source2.getPath());
+
+    Source source3 = result.getFiles().get(3);
+    assertEquals("InlineFunction.kt", source3.getFileName());
+    assertEquals("retrace/InlineFunction", source3.getPath());
+
+    // Check that the inline positions can be traced.
+    assertTrue(result.getPositions().containsKey(23));
+    Position position1 = result.getPositions().get(23);
+    assertEquals(source2, position1.getSource());
+    assertEquals(7, position1.getRange().from);
+    assertEquals(7, position1.getRange().to);
+
+    assertTrue(result.getPositions().containsKey(24));
+    Position position2 = result.getPositions().get(24);
+    assertEquals(source3, position2.getSource());
+    assertEquals(12, position2.getRange().from);
+    assertEquals(12, position2.getRange().to);
+  }
+
+  @Test
+  public void testNoKotlinHeader() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 3 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*L",
+            "1#1,22:1",
+            "7#2:23",
+            "12#3:24",
+            "*E",
+            "*S KotlinDebug",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "*L",
+            "12#1:23",
+            "18#1:24",
+            "*E");
+    assertNull(KotlinSourceDebugExtensionParser.parse(annotationData));
+  }
+
+  @Test
+  public void testIncompleteFileBlock() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 3 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*L",
+            "1#1,22:1",
+            "7#2:23",
+            "12#3:24",
+            "*E",
+            "*S KotlinDebug",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "*L",
+            "12#1:23",
+            "18#1:24",
+            "*E");
+    assertNull(KotlinSourceDebugExtensionParser.parse(annotationData));
+  }
+
+  @Test
+  public void testDuplicateFileIndex() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*S Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 1 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*L",
+            "1#1,22:1",
+            "7#2:23",
+            "12#3:24",
+            "*E",
+            "*S KotlinDebug",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "*L",
+            "12#1:23",
+            "18#1:24",
+            "*E");
+    assertNull(KotlinSourceDebugExtensionParser.parse(annotationData));
+  }
+
+  @Test
+  public void testNoDebugEntries() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*S Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 3 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*E");
+    assertNull(KotlinSourceDebugExtensionParser.parse(annotationData));
+  }
+
+  @Test
+  public void testInvalidRanges() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*S Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 3 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*L",
+            "1#bar,22:1",
+            "7#2:23",
+            "12#3:foo",
+            "*E");
+    assertNull(KotlinSourceDebugExtensionParser.parse(annotationData));
+  }
+
+  @Test
+  public void testNoSourceFileForEntry() {
+    String annotationData =
+        StringUtils.join(
+            "\n",
+            "SMAP",
+            "Main.kt",
+            "Kotlin",
+            "*S Kotlin",
+            "*F",
+            "+ 1 Main.kt",
+            "retrace/MainKt",
+            "+ 2 InlineFunction.kt",
+            "retrace/InlineFunctionKt",
+            "+ 3 InlineFunction.kt",
+            "retrace/InlineFunction",
+            "*L",
+            "1#1,22:1",
+            "7#2:23",
+            "12#4:24", // <-- non-existing file index
+            "*E");
+    Result parsedResult = KotlinSourceDebugExtensionParser.parse(annotationData);
+    assertNotNull(parsedResult);
+
+    assertEquals(2, parsedResult.getPositions().size());
+    assertTrue(parsedResult.getPositions().containsKey(1));
+    assertTrue(parsedResult.getPositions().containsKey(23));
+    assertFalse(parsedResult.getPositions().containsKey(24));
+  }
+}