Add cursor and cursor extensions for parsing klib dump files

Test: CursorTest / CursorExtensionsTest
Change-Id: Iba1cddd8103f79ad60c3cb605f1cb1a163529aae
diff --git a/binarycompatibilityvalidator/OWNERS b/binarycompatibilityvalidator/OWNERS
new file mode 100644
index 0000000..a43f7c1
--- /dev/null
+++ b/binarycompatibilityvalidator/OWNERS
@@ -0,0 +1,3 @@
[email protected]
[email protected]
[email protected]
\ No newline at end of file
diff --git a/binarycompatibilityvalidator/binarycompatibilityvalidator/build.gradle b/binarycompatibilityvalidator/binarycompatibilityvalidator/build.gradle
new file mode 100644
index 0000000..aa18e365
--- /dev/null
+++ b/binarycompatibilityvalidator/binarycompatibilityvalidator/build.gradle
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.LibraryType
+
+
+plugins {
+    id("AndroidXPlugin")
+    id("kotlin")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    implementation(libs.kotlinCompiler)
+    testImplementation(libs.truth)
+    testImplementation(libs.junit)
+}
+
+androidx {
+    name = "AndroidX Binary Compatibility Validator"
+    type = LibraryType.INTERNAL_HOST_TEST_LIBRARY
+    inceptionYear = "2024"
+    description = "Enforce binary compatibility for klibs"
+}
diff --git a/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/AbiExtensions.kt b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/AbiExtensions.kt
new file mode 100644
index 0000000..871247d
--- /dev/null
+++ b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/AbiExtensions.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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.
+ */
+
+@file:OptIn(ExperimentalLibraryAbiReader::class)
+
+package androidx.binarycompatibilityvalidator
+
+import org.jetbrains.kotlin.library.abi.AbiClassifierReference
+import org.jetbrains.kotlin.library.abi.AbiQualifiedName
+import org.jetbrains.kotlin.library.abi.AbiType
+import org.jetbrains.kotlin.library.abi.AbiTypeArgument
+import org.jetbrains.kotlin.library.abi.AbiTypeNullability
+import org.jetbrains.kotlin.library.abi.AbiVariance
+import org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader
+
+// Convenience extensions for accessing properties that may exist without have to cast repeatedly
+// For sources with documentation see https://github.com/JetBrains/kotlin/blob/master/compiler/util-klib-abi/src/org/jetbrains/kotlin/library/abi/LibraryAbi.kt
+
+/** A classifier reference is either a simple class or a type reference **/
+internal val AbiType.classifierReference: AbiClassifierReference?
+    get() = (this as? AbiType.Simple)?.classifierReference
+/** The class name from a regular type e.g. 'Array' **/
+internal val AbiType.className: AbiQualifiedName?
+    get() = classifierReference?.className
+/** A tag from a type type parameter reference e.g. 'T' **/
+internal val AbiType.tag: String?
+    get() = classifierReference?.tag
+/** The string representation of a type, whether it is a simple type or a type reference **/
+internal val AbiType.classNameOrTag: String?
+    get() = className?.toString() ?: tag
+internal val AbiType.nullability: AbiTypeNullability?
+    get() = (this as? AbiType.Simple)?.nullability
+internal val AbiType.arguments: List<AbiTypeArgument>?
+    get() = (this as? AbiType.Simple)?.arguments
+internal val AbiTypeArgument.type: AbiType?
+    get() = (this as? AbiTypeArgument.TypeProjection)?.type
+internal val AbiTypeArgument.variance: AbiVariance?
+    get() = (this as? AbiTypeArgument.TypeProjection)?.variance
+private val AbiClassifierReference.className: AbiQualifiedName?
+    get() = (this as? AbiClassifierReference.ClassReference)?.className
+private val AbiClassifierReference.tag: String?
+    get() = (this as? AbiClassifierReference.TypeParameterReference)?.tag
diff --git a/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/Cursor.kt b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/Cursor.kt
new file mode 100644
index 0000000..52f0e9b
--- /dev/null
+++ b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/Cursor.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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 androidx.binarycompatibilityvalidator
+
+class Cursor private constructor(
+    private val lines: List<String>,
+    private var rowIndex: Int = 0,
+    private var columnIndex: Int = 0
+) {
+    constructor(text: String) : this(text.split("\n"))
+    val currentLine: String
+        get() = lines[rowIndex].slice(columnIndex until lines[rowIndex].length)
+    fun hasNext() = rowIndex < (lines.size - 1)
+    fun nextLine() {
+        rowIndex++
+        columnIndex = 0
+    }
+
+    fun parseSymbol(
+        pattern: String,
+        peek: Boolean = false,
+        skipInlineWhitespace: Boolean = true
+    ): String? {
+        val match = Regex(pattern).find(currentLine)
+        return match?.value?.also {
+            if (!peek) {
+                val offset = it.length + currentLine.indexOf(it)
+                columnIndex += offset
+                if (skipInlineWhitespace) {
+                    skipInlineWhitespace()
+                }
+            }
+        }
+    }
+
+    fun parseValidIdentifier(peek: Boolean = false): String? =
+        parseSymbol("^[a-zA-Z_][a-zA-Z0-9_]+", peek)
+
+    fun parseWord(peek: Boolean = false): String? = parseSymbol("[a-zA-Z]+", peek)
+
+    fun copy() = Cursor(lines, rowIndex, columnIndex)
+
+    internal fun skipInlineWhitespace() {
+        while (currentLine.firstOrNull()?.isWhitespace() == true) {
+            columnIndex++
+        }
+    }
+}
diff --git a/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/CursorExtensions.kt b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/CursorExtensions.kt
new file mode 100644
index 0000000..2afb33a
--- /dev/null
+++ b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/main/java/androidx/binarycompatibilityvalidator/CursorExtensions.kt
@@ -0,0 +1,396 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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.
+ */
+
+// Impl classes from kotlin.library.abi.impl are necessary to instantiate parsed declarations
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+@file:OptIn(ExperimentalLibraryAbiReader::class)
+
+package androidx.binarycompatibilityvalidator
+
+import org.jetbrains.kotlin.library.abi.AbiClassKind
+import org.jetbrains.kotlin.library.abi.AbiCompoundName
+import org.jetbrains.kotlin.library.abi.AbiModality
+import org.jetbrains.kotlin.library.abi.AbiPropertyKind
+import org.jetbrains.kotlin.library.abi.AbiQualifiedName
+import org.jetbrains.kotlin.library.abi.AbiType
+import org.jetbrains.kotlin.library.abi.AbiTypeArgument
+import org.jetbrains.kotlin.library.abi.AbiTypeNullability
+import org.jetbrains.kotlin.library.abi.AbiTypeParameter
+import org.jetbrains.kotlin.library.abi.AbiValueParameter
+import org.jetbrains.kotlin.library.abi.AbiVariance
+import org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader
+import org.jetbrains.kotlin.library.abi.impl.AbiTypeParameterImpl
+import org.jetbrains.kotlin.library.abi.impl.AbiValueParameterImpl
+import org.jetbrains.kotlin.library.abi.impl.ClassReferenceImpl
+import org.jetbrains.kotlin.library.abi.impl.SimpleTypeImpl
+import org.jetbrains.kotlin.library.abi.impl.StarProjectionImpl
+import org.jetbrains.kotlin.library.abi.impl.TypeParameterReferenceImpl
+import org.jetbrains.kotlin.library.abi.impl.TypeProjectionImpl
+
+// This file contains Cursor methods specific to parsing klib dump files
+
+internal fun Cursor.parseAbiModality(): AbiModality? {
+    val parsed = parseAbiModalityString(peek = true)?.let {
+        AbiModality.valueOf(it)
+    }
+    if (parsed != null) {
+        parseAbiModalityString()
+    }
+    return parsed
+}
+
+internal fun Cursor.parseClassKind(peek: Boolean = false): AbiClassKind? {
+    val parsed = parseClassKindString(peek = true)?.let {
+        AbiClassKind.valueOf(it)
+    }
+    if (parsed != null && !peek) {
+        parseClassKindString()
+    }
+    return parsed
+}
+
+internal fun Cursor.parsePropertyKind(peek: Boolean = false): AbiPropertyKind? {
+    val parsed = parsePropertyKindString(peek = true)?.let {
+        AbiPropertyKind.valueOf(it)
+    }
+    if (parsed != null && !peek) {
+        parsePropertyKindString()
+    }
+    return parsed
+}
+
+internal fun Cursor.hasClassKind(): Boolean {
+    val subCursor = copy()
+    subCursor.skipInlineWhitespace()
+    subCursor.parseAbiModality()
+    subCursor.parseClassModifiers()
+    return subCursor.parseClassKind() != null
+}
+
+internal fun Cursor.hasFunctionKind(): Boolean {
+    val subCursor = copy()
+    subCursor.skipInlineWhitespace()
+    subCursor.parseAbiModality()
+    subCursor.parseFunctionModifiers()
+    return subCursor.parseFunctionKind() != null
+}
+
+internal fun Cursor.hasPropertyKind(): Boolean {
+    val subCursor = copy()
+    subCursor.skipInlineWhitespace()
+    subCursor.parseAbiModality()
+    return subCursor.parsePropertyKind() != null
+}
+
+internal fun Cursor.hasGetter() = hasPropertyAccessor(GetterOrSetter.GETTER)
+internal fun Cursor.hasSetter() = hasPropertyAccessor(GetterOrSetter.SETTER)
+
+internal fun Cursor.hasGetterOrSetter() = hasGetter() || hasSetter()
+
+internal fun Cursor.parseGetterName(peek: Boolean = false): String? {
+    val cursor = subCursor(peek)
+    cursor.parseSymbol("^<get\\-") ?: return null
+    val name = cursor.parseValidIdentifier() ?: return null
+    cursor.parseSymbol("^>") ?: return null
+    return "<get-$name>"
+}
+
+internal fun Cursor.parseSetterName(peek: Boolean = false): String? {
+    val cursor = subCursor(peek)
+    cursor.parseSymbol("^<set\\-") ?: return null
+    val name = cursor.parseValidIdentifier() ?: return null
+    cursor.parseSymbol("^>") ?: return null
+    return "<set-$name>"
+}
+
+internal fun Cursor.parseGetterOrSetterName(peek: Boolean = false) =
+    parseGetterName(peek) ?: parseSetterName(peek)
+
+internal fun Cursor.parseClassModifier(peek: Boolean = false): String? =
+    parseSymbol("^(inner|value|fun|open|annotation|enum)", peek)
+
+internal fun Cursor.parseClassModifiers(): Set<String> {
+    val modifiers = mutableSetOf<String>()
+    while (parseClassModifier(peek = true) != null) {
+        modifiers.add(parseClassModifier()!!)
+    }
+    return modifiers
+}
+
+internal fun Cursor.parseFunctionKind(peek: Boolean = false) =
+    parseSymbol("^(constructor|fun)", peek)
+
+internal fun Cursor.parseFunctionModifier(peek: Boolean = false): String? =
+    parseSymbol("^(inline|suspend)", peek)
+
+internal fun Cursor.parseFunctionModifiers(): Set<String> {
+    val modifiers = mutableSetOf<String>()
+    while (parseFunctionModifier(peek = true) != null) {
+        modifiers.add(parseFunctionModifier()!!)
+    }
+    return modifiers
+}
+
+internal fun Cursor.parseAbiQualifiedName(peek: Boolean = false): AbiQualifiedName? {
+    val symbol = parseSymbol("^[a-zA-Z0-9\\.]+\\/[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)?", peek)
+        ?: return null
+    val (packageName, relativeName) = symbol.split("/")
+    return AbiQualifiedName(
+        AbiCompoundName(packageName),
+        AbiCompoundName(relativeName)
+    )
+}
+
+internal fun Cursor.parseAbiType(peek: Boolean = false): AbiType? {
+    val cursor = subCursor(peek)
+    // A type will either be a qualified name (kotlin/Array) or a type reference (#A)
+    // try to parse a qualified name and a type reference if it doesn't exist
+    val abiQualifiedName = cursor.parseAbiQualifiedName()
+        ?: return cursor.parseTypeReference()
+    val typeArgs = cursor.parseTypeArgs() ?: emptyList()
+    val nullability = cursor.parseNullability(assumeNotNull = true)
+    return SimpleTypeImpl(
+        ClassReferenceImpl(abiQualifiedName),
+        arguments = typeArgs,
+        nullability = nullability
+    )
+}
+
+internal fun Cursor.parseTypeArgs(): List<AbiTypeArgument>? {
+    val typeArgsString = parseTypeParamsString() ?: return null
+    val subCursor = Cursor(typeArgsString)
+    subCursor.parseSymbol("<") ?: return null
+    val typeArgs = mutableListOf<AbiTypeArgument>()
+    while (subCursor.parseTypeArg(peek = true) != null) {
+        typeArgs.add(subCursor.parseTypeArg()!!)
+        subCursor.parseSymbol(",")
+    }
+    return typeArgs
+}
+
+internal fun Cursor.parseTypeArg(peek: Boolean = false): AbiTypeArgument? {
+    val cursor = subCursor(peek)
+    val variance = cursor.parseAbiVariance()
+    cursor.parseSymbol("\\*")?.let {
+        return StarProjectionImpl
+    }
+    val type = cursor.parseAbiType(peek) ?: return null
+    return TypeProjectionImpl(
+        type = type,
+        variance = variance
+    )
+}
+
+internal fun Cursor.parseAbiVariance(): AbiVariance {
+    val variance = parseSymbol("^(out|in)") ?: return AbiVariance.INVARIANT
+    return AbiVariance.valueOf(variance.uppercase())
+}
+
+internal fun Cursor.parseTypeReference(): AbiType? {
+    val typeParamReference = parseTag() ?: return null
+    val typeArgs = parseTypeArgs() ?: emptyList()
+    val nullability = parseNullability()
+    return SimpleTypeImpl(
+        TypeParameterReferenceImpl(typeParamReference),
+        arguments = typeArgs,
+        nullability = nullability
+    )
+}
+
+internal fun Cursor.parseTag() = parseSymbol("^#[a-zA-Z0-9]+")?.removePrefix("#")
+
+internal fun Cursor.parseNullability(assumeNotNull: Boolean = false): AbiTypeNullability {
+    val nullable = parseSymbol("^\\?") != null
+    val definitelyNotNull = parseSymbol("^\\!\\!") != null
+    return when {
+        nullable -> AbiTypeNullability.MARKED_NULLABLE
+        definitelyNotNull -> AbiTypeNullability.DEFINITELY_NOT_NULL
+        else -> if (assumeNotNull) {
+            AbiTypeNullability.DEFINITELY_NOT_NULL
+        } else {
+            AbiTypeNullability.NOT_SPECIFIED
+        }
+    }
+}
+
+internal fun Cursor.parseSuperTypes(): MutableSet<AbiType> {
+    parseSymbol(":")
+    val superTypes = mutableSetOf<AbiType>()
+    while (parseAbiQualifiedName(peek = true) != null) {
+        superTypes.add(parseAbiType()!!)
+        parseSymbol(",")
+    }
+    return superTypes
+}
+
+fun Cursor.parseTypeParams(peek: Boolean = false): List<AbiTypeParameter>? {
+    val typeParamsString = parseTypeParamsString(peek) ?: return null
+    val subCursor = Cursor(typeParamsString)
+    subCursor.parseSymbol("^<")
+    val typeParams = mutableListOf<AbiTypeParameter>()
+    while (subCursor.parseTypeParam(peek = true) != null) {
+        typeParams.add(subCursor.parseTypeParam()!!)
+        subCursor.parseSymbol("^,")
+    }
+    return typeParams
+}
+
+fun Cursor.parseTypeParam(peek: Boolean = false): AbiTypeParameter? {
+    val cursor = subCursor(peek)
+    val tag = cursor.parseTag() ?: return null
+    cursor.parseSymbol("^:")
+    val variance = cursor.parseAbiVariance()
+    val isReified = cursor.parseSymbol("reified") != null
+    val upperBounds = mutableListOf<AbiType>()
+    if (null != cursor.parseAbiType(peek = true)) {
+        upperBounds.add(cursor.parseAbiType()!!)
+    }
+
+    return AbiTypeParameterImpl(
+        tag = tag,
+        variance = variance,
+        isReified = isReified,
+        upperBounds = upperBounds
+    )
+}
+
+internal fun Cursor.parseValueParameters(): List<AbiValueParameter>? {
+    val valueParamString = parseValueParametersString() ?: return null
+    val subCursor = Cursor(valueParamString)
+    val valueParams = mutableListOf<AbiValueParameter>()
+    subCursor.parseSymbol("\\(")
+    while (null != subCursor.parseValueParameter(peek = true)) {
+        valueParams.add(subCursor.parseValueParameter()!!)
+        subCursor.parseSymbol("^,")
+    }
+    return valueParams
+}
+
+internal fun Cursor.parseValueParameter(peek: Boolean = false): AbiValueParameter? {
+    val cursor = subCursor(peek)
+    val modifiers = cursor.parseValueParameterModifiers()
+    val isNoInline = modifiers.contains("noinline")
+    val isCrossinline = modifiers.contains("crossinline")
+    val type = cursor.parseAbiType() ?: return null
+    val isVararg = cursor.parseVarargSymbol() != null
+    val hasDefaultArg = cursor.parseDefaultArg() != null
+    return AbiValueParameterImpl(
+        type = type,
+        isVararg = isVararg,
+        hasDefaultArg = hasDefaultArg,
+        isNoinline = isNoInline,
+        isCrossinline = isCrossinline
+    )
+}
+
+internal fun Cursor.parseValueParameterModifiers(): Set<String> {
+    val modifiers = mutableSetOf<String>()
+    while (parseValueParameterModifier(peek = true) != null) {
+        modifiers.add(parseValueParameterModifier()!!)
+    }
+    return modifiers
+}
+
+internal fun Cursor.parseValueParameterModifier(peek: Boolean = false): String? =
+    parseSymbol("^(crossinline|noinline)", peek)
+
+internal fun Cursor.parseVarargSymbol() = parseSymbol("^\\.\\.\\.")
+
+internal fun Cursor.parseDefaultArg() = parseSymbol("^=\\.\\.\\.")
+
+internal fun Cursor.parseFunctionReceiver(): AbiType? {
+    val string = parseFunctionReceiverString() ?: return null
+    val subCursor = Cursor(string)
+    subCursor.parseSymbol("\\(")
+    return subCursor.parseAbiType()
+}
+
+internal fun Cursor.parseReturnType(): AbiType? {
+    parseSymbol("^:\\s")
+    return parseAbiType()
+}
+
+internal fun Cursor.parseTargets(): List<String> {
+    parseSymbol("^Targets:")
+    parseSymbol("^\\[")
+    val targets = mutableListOf<String>()
+    while (parseValidIdentifier(peek = true) != null) {
+        targets.add(parseValidIdentifier()!!)
+        parseSymbol("^,")
+    }
+    parseSymbol("^\\]")
+    return targets
+}
+
+/**
+ * Used to check if declarations after a property are getter / setter methods which should be
+ * attached to that property.
+*/
+private fun Cursor.hasPropertyAccessor(type: GetterOrSetter): Boolean {
+    val subCursor = copy()
+    subCursor.parseAbiModality()
+    subCursor.parseFunctionModifiers()
+    subCursor.parseFunctionKind() ?: return false // if it's not a function it's not a getter/setter
+    val mightHaveTypeParams = subCursor.parseGetterOrSetterName(peek = true) == null
+    if (mightHaveTypeParams) {
+        subCursor.parseTypeParams()
+    }
+    subCursor.parseFunctionReceiver()
+    return when (type) {
+        GetterOrSetter.GETTER -> subCursor.parseGetterName() != null
+        GetterOrSetter.SETTER -> subCursor.parseSetterName() != null
+    }
+}
+
+private fun Cursor.subCursor(peek: Boolean) = if (peek) { copy() } else { this }
+
+private fun Cursor.parseTypeParamsString(peek: Boolean = false): String? {
+    val cursor = subCursor(peek)
+    val result = StringBuilder()
+    cursor.parseSymbol("^<")?.let { result.append(it) } ?: return null
+    var openBracketCount = 1
+    while (openBracketCount > 0) {
+        val nextSymbol = cursor.parseSymbol(".", skipInlineWhitespace = false).also {
+            result.append(it)
+        }
+        when (nextSymbol) {
+            "<" -> openBracketCount++
+            ">" -> openBracketCount--
+        }
+    }
+    cursor.skipInlineWhitespace()
+    return result.toString()
+}
+
+private fun Cursor.parseFunctionReceiverString() =
+    parseSymbol("^\\([a-zA-Z0-9,\\/<>,#\\.\\s]+?\\)\\.")
+
+private fun Cursor.parseValueParametersString() =
+    parseSymbol("^\\(([a-zA-Z0-9,\\/<>,#\\.\\s\\?=]+)?\\)")
+
+private fun Cursor.parseAbiModalityString(peek: Boolean = false) =
+    parseSymbol("^(final|open|abstract|sealed)", peek)?.uppercase()
+
+private fun Cursor.parsePropertyKindString(peek: Boolean = false) =
+    parseSymbol("^(const\\sval|val|var)", peek)?.uppercase()?.replace(" ", "_")
+
+private fun Cursor.parseClassKindString(peek: Boolean = false) =
+    parseSymbol("^(class|interface|object|enum_class|annotation_class)", peek)?.uppercase()
+
+private enum class GetterOrSetter() {
+    GETTER,
+    SETTER
+}
diff --git a/binarycompatibilityvalidator/binarycompatibilityvalidator/src/test/java/androidx/binarycompatibilityvalidator/CursorExtensionsTest.kt b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/test/java/androidx/binarycompatibilityvalidator/CursorExtensionsTest.kt
new file mode 100644
index 0000000..6390ea9
--- /dev/null
+++ b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/test/java/androidx/binarycompatibilityvalidator/CursorExtensionsTest.kt
@@ -0,0 +1,509 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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.
+ */
+
+@file:OptIn(ExperimentalLibraryAbiReader::class)
+
+package androidx.binarycompatibilityvalidator
+
+import com.google.common.truth.Truth.assertThat
+import org.jetbrains.kotlin.library.abi.AbiClassKind
+import org.jetbrains.kotlin.library.abi.AbiModality
+import org.jetbrains.kotlin.library.abi.AbiPropertyKind
+import org.jetbrains.kotlin.library.abi.AbiTypeNullability
+import org.jetbrains.kotlin.library.abi.AbiValueParameter
+import org.jetbrains.kotlin.library.abi.AbiVariance
+import org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader
+import org.junit.Test
+
+class CursorExtensionsTest {
+
+    @Test
+    fun parseModalityFailure() {
+        val input = "something else"
+        val cursor = Cursor(input)
+        val modality = cursor.parseAbiModality()
+        assertThat(modality).isNull()
+        assertThat(cursor.currentLine).isEqualTo("something else")
+    }
+
+    @Test
+    fun parseModalitySuccess() {
+        val input = "final whatever"
+        val cursor = Cursor(input)
+        val modality = cursor.parseAbiModality()
+        assertThat(modality).isEqualTo(AbiModality.FINAL)
+        assertThat(cursor.currentLine).isEqualTo("whatever")
+    }
+
+    @Test
+    fun parseClassModifier() {
+        val input = "inner whatever"
+        val cursor = Cursor(input)
+        val modifier = cursor.parseClassModifier()
+        assertThat(modifier).isEqualTo("inner")
+        assertThat(cursor.currentLine).isEqualTo("whatever")
+    }
+
+    @Test
+    fun parseClassModifiers() {
+        val input = "inner value fun whatever"
+        val cursor = Cursor(input)
+        val modifiers = cursor.parseClassModifiers()
+        assertThat(modifiers).containsExactly("inner", "fun", "value")
+        assertThat(cursor.currentLine).isEqualTo("whatever")
+    }
+
+    @Test
+    fun parseFunctionModifiers() {
+        val input = "final inline suspend fun component1(): kotlin/Long"
+        val cursor = Cursor(input)
+        cursor.parseAbiModality()
+        val modifiers = cursor.parseFunctionModifiers()
+        assertThat(modifiers).containsExactly("inline", "suspend")
+        assertThat(cursor.currentLine).isEqualTo("fun component1(): kotlin/Long")
+    }
+
+    @Test
+    fun parseClassKindSimple() {
+        val input = "class"
+        val cursor = Cursor(input)
+        val kind = cursor.parseClassKind()
+        assertThat(kind).isEqualTo(AbiClassKind.CLASS)
+    }
+
+    @Test
+    fun parseClassKindFalsePositive() {
+        val input = "androidx.collection/objectFloatMap"
+        val cursor = Cursor(input)
+        val kind = cursor.parseClassKind()
+        assertThat(kind).isNull()
+    }
+
+    @Test
+    fun hasClassKind() {
+        val input = "final class my.lib/MyClass"
+        val cursor = Cursor(input)
+        assertThat(cursor.hasClassKind()).isTrue()
+        assertThat(cursor.currentLine).isEqualTo(input)
+    }
+
+    @Test
+    fun parseFunctionKindSimple() {
+        val input = "fun hello"
+        val cursor = Cursor(input)
+        val kind = cursor.parseFunctionKind()
+        assertThat(kind).isEqualTo("fun")
+        assertThat(cursor.currentLine).isEqualTo(cursor.currentLine)
+    }
+
+    @Test fun hasFunctionKind() {
+        val input = "    final fun myFun(): kotlin/String "
+        val cursor = Cursor(input)
+        assertThat(cursor.hasFunctionKind()).isTrue()
+        assertThat(cursor.currentLine).isEqualTo(input)
+    }
+
+    @Test fun hasFunctionKindConstructor() {
+        val input = "    constructor <init>(kotlin/Int =...)"
+        val cursor = Cursor(input)
+        assertThat(cursor.hasFunctionKind()).isTrue()
+        assertThat(cursor.currentLine).isEqualTo(input)
+    }
+
+    @Test fun parseGetterOrSetterName() {
+        val input = "<get-indices>()"
+        val cursor = Cursor(input)
+        val name = cursor.parseGetterOrSetterName()
+        assertThat(name).isEqualTo("<get-indices>")
+        assertThat(cursor.currentLine).isEqualTo("()")
+    }
+
+    @Test fun hasGetter() {
+        val input = "final inline fun <get-indices>(): kotlin.ranges/IntRange"
+        val cursor = Cursor(input)
+        assertThat(cursor.hasGetter()).isTrue()
+        assertThat(cursor.currentLine).isEqualTo(input)
+    }
+
+    @Test fun hasSetter() {
+        val input = "final inline fun <set-indices>(): kotlin.ranges/IntRange"
+        val cursor = Cursor(input)
+        assertThat(cursor.hasSetter()).isTrue()
+        assertThat(cursor.currentLine).isEqualTo(input)
+    }
+
+    @Test fun hasGetterOrSetter() {
+        val inputs = listOf(
+            "final inline fun <set-indices>(): kotlin.ranges/IntRange",
+            "final inline fun <get-indices>(): kotlin.ranges/IntRange"
+        )
+        inputs.forEach { input ->
+            assertThat(Cursor(input).hasGetterOrSetter()).isTrue()
+        }
+    }
+
+    @Test
+    fun hasPropertyKind() {
+        val input = "final const val my.lib/myProp"
+        val cursor = Cursor(input)
+        assertThat(cursor.hasPropertyKind()).isTrue()
+        assertThat(cursor.currentLine).isEqualTo(input)
+    }
+
+    @Test
+    fun parsePropertyKindConstVal() {
+        val input = "const val something"
+        val cursor = Cursor(input)
+        val kind = cursor.parsePropertyKind()
+        assertThat(kind).isEqualTo(AbiPropertyKind.CONST_VAL)
+        assertThat(cursor.currentLine).isEqualTo("something")
+    }
+
+    @Test
+    fun parsePropertyKindVal() {
+        val input = "val something"
+        val cursor = Cursor(input)
+        val kind = cursor.parsePropertyKind()
+        assertThat(kind).isEqualTo(AbiPropertyKind.VAL)
+        assertThat(cursor.currentLine).isEqualTo("something")
+    }
+
+    @Test
+    fun parseNullability() {
+        val nullable = Cursor("?").parseNullability()
+        val notNull = Cursor("!!").parseNullability()
+        val unspecified = Cursor("another symbol").parseNullability()
+        assertThat(nullable).isEqualTo(AbiTypeNullability.MARKED_NULLABLE)
+        assertThat(notNull).isEqualTo(AbiTypeNullability.DEFINITELY_NOT_NULL)
+        assertThat(unspecified).isEqualTo(AbiTypeNullability.NOT_SPECIFIED)
+    }
+
+    @Test fun parseNullabilityWhenAssumingNotNullable() {
+        val unspecified = Cursor("").parseNullability(assumeNotNull = true)
+        assertThat(unspecified).isEqualTo(AbiTypeNullability.DEFINITELY_NOT_NULL)
+    }
+
+    @Test fun parseQualifiedName() {
+        val input = "androidx.collection/MutableScatterMap something"
+        val cursor = Cursor(input)
+        val qName = cursor.parseAbiQualifiedName()
+        assertThat(qName.toString()).isEqualTo("androidx.collection/MutableScatterMap")
+        assertThat(cursor.currentLine).isEqualTo("something")
+    }
+
+    @Test fun parseQualifiedNameKotlin() {
+        val input = "kotlin/Function2<#A1, #A, #A1>"
+        val cursor = Cursor(input)
+        val qName = cursor.parseAbiQualifiedName()
+        assertThat(qName.toString()).isEqualTo("kotlin/Function2")
+        assertThat(cursor.currentLine).isEqualTo("<#A1, #A, #A1>",)
+    }
+
+    @Test fun parseQualifie0dNameDoesNotGrabNullable() {
+        val input = "androidx.collection/MutableScatterMap? something"
+        val cursor = Cursor(input)
+        val qName = cursor.parseAbiQualifiedName()
+        assertThat(qName.toString()).isEqualTo("androidx.collection/MutableScatterMap")
+        assertThat(cursor.currentLine).isEqualTo("? something")
+    }
+
+    @Test
+    fun parseAbiType() {
+        val input = "androidx.collection/ScatterMap<#A, #B> something"
+        val cursor = Cursor(input)
+        val type = cursor.parseAbiType()
+        assertThat(type?.className?.toString()).isEqualTo(
+            "androidx.collection/ScatterMap"
+        )
+        assertThat(cursor.currentLine).isEqualTo("something")
+    }
+
+    @Test
+    fun parseAbiTypeWithAnotherType() {
+        val input = "androidx.collection/ScatterMap<#A, #B>, androidx.collection/Other<#A, #B> " +
+            "something"
+        val cursor = Cursor(input)
+        val type = cursor.parseAbiType()
+        assertThat(type?.className?.toString()).isEqualTo(
+            "androidx.collection/ScatterMap"
+        )
+        assertThat(cursor.currentLine).isEqualTo(
+            ", androidx.collection/Other<#A, #B> something"
+        )
+    }
+
+    @Test fun parseAbiTypeWithThreeParams() {
+        val input = "kotlin/Function2<#A1, #A, #A1>"
+        val cursor = Cursor(input)
+        val type = cursor.parseAbiType()
+        assertThat(type?.className?.toString()).isEqualTo("kotlin/Function2")
+    }
+
+    @Test
+    fun parseSuperTypes() {
+        val input = ": androidx.collection/ScatterMap<#A, #B>, androidx.collection/Other<#A, #B> " +
+            "something"
+        val cursor = Cursor(input)
+        val superTypes = cursor.parseSuperTypes().toList()
+        assertThat(superTypes).hasSize(2)
+        assertThat(superTypes.first().className?.toString()).isEqualTo(
+            "androidx.collection/ScatterMap"
+        )
+        assertThat(superTypes.last().className?.toString()).isEqualTo(
+            "androidx.collection/Other"
+        )
+        assertThat(cursor.currentLine).isEqualTo("something")
+    }
+
+    @Test fun parseReturnType() {
+        val input = ": androidx.collection/ScatterMap<#A, #B> stuff"
+        val cursor = Cursor(input)
+        val returnType = cursor.parseReturnType()
+        assertThat(returnType?.className?.toString()).isEqualTo(
+            "androidx.collection/ScatterMap"
+        )
+        assertThat(cursor.currentLine).isEqualTo("stuff")
+    }
+
+    @Test fun parseReturnTypeNullableWithTypeParamsNullable() {
+        val input = ": #B? stuff"
+        val cursor = Cursor(input)
+        val returnType = cursor.parseReturnType()
+        assertThat(returnType?.tag).isEqualTo("B")
+        assertThat(returnType?.nullability).isEqualTo(AbiTypeNullability.MARKED_NULLABLE)
+        assertThat(cursor.currentLine).isEqualTo("stuff")
+    }
+
+    @Test fun parseReturnTypeNullableWithTypeParamsNotSpecified() {
+        val input = ": #B stuff"
+        val cursor = Cursor(input)
+        val returnType = cursor.parseReturnType()
+        assertThat(returnType?.tag).isEqualTo("B")
+        assertThat(returnType?.nullability).isEqualTo(AbiTypeNullability.NOT_SPECIFIED)
+        assertThat(cursor.currentLine).isEqualTo("stuff")
+    }
+
+    @Test
+    fun parseFunctionReceiver() {
+        val input = "(androidx.collection/LongSparseArray<#A>).androidx.collection/keyIterator()"
+        val cursor = Cursor(input)
+        val receiver = cursor.parseFunctionReceiver()
+        assertThat(receiver?.className.toString()).isEqualTo(
+            "androidx.collection/LongSparseArray"
+        )
+        assertThat(cursor.currentLine).isEqualTo("androidx.collection/keyIterator()")
+    }
+
+    @Test
+    fun parseFunctionReceiver2() {
+        val input = "(androidx.collection/LongSparseArray<#A1>).<get-size>(): kotlin/Int"
+        val cursor = Cursor(input)
+        val receiver = cursor.parseFunctionReceiver()
+        assertThat(receiver?.className.toString()).isEqualTo(
+            "androidx.collection/LongSparseArray"
+        )
+        assertThat(cursor.currentLine).isEqualTo("<get-size>(): kotlin/Int")
+    }
+
+    @Test fun parseValueParamCrossinlineDefault() {
+        val input = "crossinline kotlin/Function2<#A, #B, kotlin/Int> =..."
+        val cursor = Cursor(input)
+        val valueParam = cursor.parseValueParameter()!!
+        assertThat(
+            valueParam.type.className.toString()
+        ).isEqualTo("kotlin/Function2")
+        assertThat(valueParam.hasDefaultArg).isTrue()
+        assertThat(valueParam.isCrossinline).isTrue()
+        assertThat(valueParam.isVararg).isFalse()
+    }
+
+    @Test
+    fun parseValueParamVararg() {
+        val input = "kotlin/Array<out kotlin/Pair<#A, #B>>..."
+        val cursor = Cursor(input)
+        val valueParam = cursor.parseValueParameter()
+        assertThat(
+            valueParam?.type?.className?.toString()
+        ).isEqualTo("kotlin/Array")
+        assertThat(valueParam?.hasDefaultArg).isFalse()
+        assertThat(valueParam?.isCrossinline).isFalse()
+        assertThat(valueParam?.isVararg).isTrue()
+    }
+
+    @Test fun parseValueParametersWithTypeArgs() {
+        val input = "kotlin/Array<out #A>..."
+        val cursor = Cursor(input)
+        val valueParam = cursor.parseValueParameter()
+        assertThat(valueParam?.type?.arguments).hasSize(1)
+    }
+
+    @Test fun parseValueParametersWithTwoTypeArgs() {
+        val input = "kotlin/Function1<kotlin/Double, kotlin/Boolean>)"
+        val cursor = Cursor(input)
+        val valueParam = cursor.parseValueParameter()
+        assertThat(valueParam?.type?.arguments).hasSize(2)
+    }
+
+    @Test fun parseValueParametersEmpty() {
+        val input = "() thing"
+        val cursor = Cursor(input)
+        val params = cursor.parseValueParameters()
+        assertThat(params).isEqualTo(emptyList<AbiValueParameter>())
+        assertThat(cursor.currentLine).isEqualTo("thing")
+    }
+
+    @Test fun parseValueParamsSimple() {
+        val input = "(kotlin/Function1<#A, kotlin/Boolean>)"
+        val cursor = Cursor(input)
+        val valueParams = cursor.parseValueParameters()
+        assertThat(valueParams).hasSize(1)
+    }
+
+    @Test fun parseValueParamsTwoArgs() {
+        val input = "(#A1, kotlin/Function2<#A1, #A, #A1>)"
+        val cursor = Cursor(input)
+        val valueParams = cursor.parseValueParameters()
+        assertThat(valueParams).hasSize(2)
+        assertThat(valueParams?.first()?.type?.tag).isEqualTo("A1")
+    }
+
+    @Test
+    fun parseValueParamsWithHasDefaultArg() {
+        val input = "(kotlin/Int =...)"
+        val cursor = Cursor(input)
+        val valueParams = cursor.parseValueParameters()
+        assertThat(valueParams).hasSize(1)
+        assertThat(valueParams?.single()?.hasDefaultArg).isTrue()
+    }
+
+    @Test
+    fun parseValueParamsComplex2() {
+        val input = "(kotlin/Int, crossinline kotlin/Function2<#A, #B, kotlin/Int> =..., " +
+            "crossinline kotlin/Function1<#A, #B?> =..., " +
+            "crossinline kotlin/Function4<kotlin/Boolean, #A, #B, #B?, kotlin/Unit> =...)"
+        val cursor = Cursor(input)
+        val valueParams = cursor.parseValueParameters()!!
+        assertThat(valueParams).hasSize(4)
+        assertThat(valueParams.first().type.className?.toString()).isEqualTo("kotlin/Int")
+        val rest = valueParams.subList(1, valueParams.size)
+        assertThat(rest).hasSize(3)
+        assertThat(rest.all { it.hasDefaultArg }).isTrue()
+        assertThat(rest.all { it.isCrossinline }).isTrue()
+    }
+
+    @Test fun parseValueParamsComplex3() {
+        val input = "(kotlin/Array<out kotlin/Pair<#A, #B>>...)"
+        val cursor = Cursor(input)
+        val valueParams = cursor.parseValueParameters()!!
+        assertThat(valueParams).hasSize(1)
+
+        assertThat(valueParams.single().isVararg).isTrue()
+        val type = valueParams.single().type
+        assertThat(type.className.toString()).isEqualTo("kotlin/Array")
+    }
+
+    @Test fun parseTypeParams() {
+        val input = "<#A1: kotlin/Any?>"
+        val cursor = Cursor(input)
+        val typeParams = cursor.parseTypeParams()
+        assertThat(typeParams).hasSize(1)
+        val type = typeParams?.single()?.upperBounds?.single()
+        assertThat(typeParams?.single()?.tag).isEqualTo("A1")
+        assertThat(type?.className?.toString()).isEqualTo("kotlin/Any")
+        assertThat(type?.nullability).isEqualTo(AbiTypeNullability.MARKED_NULLABLE)
+        assertThat(typeParams?.single()?.variance).isEqualTo(AbiVariance.INVARIANT)
+    }
+
+    @Test fun parseTypeParamsWithVariance() {
+        val input = "<#A1: out kotlin/Any?>"
+        val cursor = Cursor(input)
+        val typeParams = cursor.parseTypeParams()
+        assertThat(typeParams).hasSize(1)
+        val type = typeParams?.single()?.upperBounds?.single()
+        assertThat(typeParams?.single()?.tag).isEqualTo("A1")
+        assertThat(type?.className?.toString()).isEqualTo("kotlin/Any")
+        assertThat(type?.nullability).isEqualTo(AbiTypeNullability.MARKED_NULLABLE)
+        assertThat(typeParams?.single()?.variance).isEqualTo(AbiVariance.OUT)
+    }
+
+    @Test fun parseTypeParamsWithTwo() {
+        val input = "<#A: kotlin/Any?, #B: kotlin/Any?>"
+        val cursor = Cursor(input)
+        val typeParams = cursor.parseTypeParams()
+        assertThat(typeParams).hasSize(2)
+        val type1 = typeParams?.first()?.upperBounds?.single()
+        val type2 = typeParams?.first()?.upperBounds?.single()
+        assertThat(typeParams?.first()?.tag).isEqualTo("A")
+        assertThat(typeParams?.last()?.tag).isEqualTo("B")
+        assertThat(type1?.className?.toString()).isEqualTo("kotlin/Any")
+        assertThat(type1?.nullability).isEqualTo(AbiTypeNullability.MARKED_NULLABLE)
+        assertThat(type2?.className?.toString()).isEqualTo("kotlin/Any")
+        assertThat(type2?.nullability).isEqualTo(AbiTypeNullability.MARKED_NULLABLE)
+    }
+
+    @Test fun parseTypeParamsReifed() {
+        val input = "<#A1: reified kotlin/Any?>"
+        val cursor = Cursor(input)
+        val typeParam = cursor.parseTypeParams()?.single()
+        assertThat(typeParam).isNotNull()
+        assertThat(typeParam?.isReified).isTrue()
+    }
+
+    @Test
+    fun parseTypeArgs() {
+        val input = "<out #A>"
+        val cursor = Cursor(input)
+        val typeArgs = cursor.parseTypeArgs()
+        assertThat(typeArgs).hasSize(1)
+        val typeArg = typeArgs?.single()
+        assertThat(typeArg?.type?.tag).isEqualTo("A")
+        assertThat(typeArg?.variance).isEqualTo(AbiVariance.OUT)
+    }
+
+    @Test
+    fun parseTwoTypeArgs() {
+        val input = "<kotlin/Double, kotlin/Boolean>"
+        val cursor = Cursor(input)
+        val typeArgs = cursor.parseTypeArgs()
+        assertThat(typeArgs).hasSize(2)
+        assertThat(typeArgs?.first()?.type?.className?.toString()).isEqualTo("kotlin/Double")
+        assertThat(typeArgs?.last()?.type?.className?.toString()).isEqualTo("kotlin/Boolean")
+    }
+
+    @Test
+    fun parseTypeArgsWithNestedBrackets() {
+        val input = "<androidx.collection/ScatterMap<#A, #B>, androidx.collection/Other<#A, #B>>," +
+            " something else"
+        val cursor = Cursor(input)
+        val typeArgs = cursor.parseTypeArgs()
+        assertThat(typeArgs).hasSize(2)
+        assertThat(cursor.currentLine).isEqualTo(", something else")
+    }
+
+    @Test fun parseVarargSymbol() {
+        val input = "..."
+        val cursor = Cursor(input)
+        val vararg = cursor.parseVarargSymbol()
+        assertThat(vararg).isNotNull()
+    }
+
+    @Test fun parseTargets() {
+        val input = "Targets: [iosX64, linuxX64]"
+        val cursor = Cursor(input)
+        val targets = cursor.parseTargets()
+        assertThat(targets).containsExactly("linuxX64", "iosX64")
+    }
+}
diff --git a/binarycompatibilityvalidator/binarycompatibilityvalidator/src/test/java/androidx/binarycompatibilityvalidator/CursorTest.kt b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/test/java/androidx/binarycompatibilityvalidator/CursorTest.kt
new file mode 100644
index 0000000..f37f6c5
--- /dev/null
+++ b/binarycompatibilityvalidator/binarycompatibilityvalidator/src/test/java/androidx/binarycompatibilityvalidator/CursorTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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 androidx.binarycompatibilityvalidator
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class CursorTest {
+
+    @Test
+    fun cursorShowsCurrentLine() {
+        val input = "one\ntwo\nthree"
+        val cursor = Cursor(input)
+        assertThat(cursor.currentLine).isEqualTo("one")
+        cursor.nextLine()
+        assertThat(cursor.currentLine).isEqualTo("two")
+        cursor.nextLine()
+        assertThat(cursor.currentLine).isEqualTo("three")
+        assertThat(cursor.hasNext()).isFalse()
+    }
+
+    @Test
+    fun cursorGetsNextWord() {
+        val input = "one two three"
+        val cursor = Cursor(input)
+        val word = cursor.parseWord()
+        assertThat(word).isEqualTo("one")
+        assertThat("two three").isEqualTo(cursor.currentLine)
+    }
+
+    @Test
+    fun parseValidIdentifierValid() {
+        val input = "oneTwo3 four"
+        val cursor = Cursor(input)
+        val symbol = cursor.parseValidIdentifier()
+        assertThat(symbol).isEqualTo("oneTwo3")
+        assertThat("four").isEqualTo(cursor.currentLine)
+    }
+
+    @Test
+    fun parseValidIdentifierValidStartsWithUnderscore() {
+        val input = "_one_Two3 four"
+        val cursor = Cursor(input)
+        val symbol = cursor.parseValidIdentifier()
+        assertThat(symbol).isEqualTo("_one_Two3")
+        assertThat("four").isEqualTo(cursor.currentLine)
+    }
+
+    @Test
+    fun parseValidIdentifierInvalid() {
+        val input = "1twothree"
+        val cursor = Cursor(input)
+        val symbol = cursor.parseValidIdentifier()
+        assertThat(symbol).isNull()
+    }
+
+    @Test
+    fun skipWhitespace() {
+        val input = "    test"
+        val cursor = Cursor(input)
+        cursor.skipInlineWhitespace()
+        assertThat(cursor.currentLine).isEqualTo("test")
+    }
+
+    @Test
+    fun skipWhitespaceOnBlankLine() {
+        val input = ""
+        val cursor = Cursor(input)
+        cursor.skipInlineWhitespace()
+        assertThat(cursor.currentLine).isEqualTo("")
+    }
+}
diff --git a/settings.gradle b/settings.gradle
index 20624d7..8473061 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -405,6 +405,7 @@
 includeProject(":benchmark:integration-tests:macrobenchmark", [BuildType.MAIN])
 includeProject(":benchmark:integration-tests:macrobenchmark-target", [BuildType.MAIN])
 includeProject(":benchmark:integration-tests:startup-benchmark", [BuildType.MAIN])
+includeProject(":binarycompatibilityvalidator:binarycompatibilityvalidator", [BuildType.MAIN])
 includeProject(":biometric:biometric", [BuildType.MAIN])
 includeProject(":biometric:biometric-ktx", [BuildType.MAIN])
 includeProject(":biometric:biometric-ktx-samples", "biometric/biometric-ktx/samples", [BuildType.MAIN])