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));
+ }
+}