fix(lsp/java): add parser for parsing api-versions.xml

This commit is contained in:
Akash Yadav
2024-05-28 00:01:50 +05:30
parent 8a1437791d
commit 255d8e9c27
8 changed files with 603 additions and 14 deletions
+3 -1
View File
@@ -64,13 +64,15 @@ dependencies {
implementation(projects.subprojects.javacServices)
implementation(libs.composite.javac)
implementation(libs.composite.jdkJdeps)
implementation(libs.composite.javapoet)
implementation(libs.composite.jaxp)
implementation(libs.composite.jdkJdeps)
implementation(libs.composite.googleJavaFormat)
implementation(libs.androidx.core.ktx)
implementation(libs.common.kotlin)
testImplementation(projects.testing.common)
testImplementation(projects.testing.lsp)
androidTestImplementation(projects.testing.android)
androidTestImplementation(projects.common)
@@ -17,6 +17,7 @@
package com.itsaky.androidide.lsp.java.indexing
import androidx.annotation.VisibleForTesting
import com.itsaky.androidide.lsp.java.indexing.classfile.AnnotationAnnotationElementValue
import com.itsaky.androidide.lsp.java.indexing.classfile.AnnotationElement
import com.itsaky.androidide.lsp.java.indexing.classfile.AnnotationElement.Companion.newAnnotationElement
@@ -68,6 +69,7 @@ class JavaJarModelBuilder(private val jar: File) {
// for testing purposes
// prefer using consumeTypes(Function)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun buildTypes(): List<IJavaType<*, *>> {
val types = mutableListOf<IJavaType<*, *>>()
consumeTypes { types.add(it) }
@@ -41,13 +41,16 @@ open class ApiInfo : ISharedJavaIndexable, ICloneable {
override var id: Int? = null
@RealmField("since")
private var since: Int = 1
var since: Int = 1
private set
@RealmField("deprecatedIn")
private var deprecatedIn: Int = 0
var deprecatedIn: Int = 0
private set
@RealmField("removedIn")
private var removedIn: Int = 0
var removedIn: Int = 0
private set
override fun computeId() {
this.id = Objects.hashCode(this.since, this.removedIn, this.deprecatedIn)
@@ -0,0 +1,184 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.lsp.java.indexing.apiinfo
import androidx.collection.mutableIntObjectMapOf
import jaxp.xml.namespace.QName
import jaxp.xml.stream.XMLInputFactory
import jaxp.xml.stream.events.Attribute
import jaxp.xml.stream.events.EndElement
import jaxp.xml.stream.events.StartElement
import org.slf4j.LoggerFactory
import java.io.InputStream
/**
* Parser for parsing `api-versions.xml` file from the Android SDK and building [ApiInfo] models.
*
* @author Akash Yadav
*/
class ApiInfoParser {
private val apiInfos = mutableIntObjectMapOf<ApiInfo>()
private val apiInfo = HashMap<String, Pair<ApiInfo, HashMap<String, ApiInfo>>>()
private var apiVersion: Int? = null
private var currentClass: String? = null
companion object {
private val log = LoggerFactory.getLogger(ApiInfoParser::class.java)
}
fun parse(apiVersionsXml: InputStream) {
if (apiVersionsXml.available() <= 0) {
log.warn("api-versions.xml InputStream is empty")
return
}
val inputFactory = XMLInputFactory.newInstance()
val reader = inputFactory.createXMLEventReader(apiVersionsXml)
while (reader.hasNext()) {
val event = reader.nextEvent()
if (event.isStartElement) {
consumeStartElement(event.asStartElement())
continue
}
if (event.isEndElement) {
consumeEndElement(event.asEndElement())
}
}
}
/**
* Returns [ApiInfo] for the given class.
*/
fun getClassInfo(className: String): ApiInfo? {
return apiInfo[className]?.first
}
/**
* Returns [ApiInfo] for the given class and member.
*/
fun getMemberInfo(className: String, memberName: String): ApiInfo? {
return apiInfo[className]?.second?.get(memberName)
}
/**
* Removes and returns the [ApiInfo] for the given class and member. This also removes the [ApiInfo]
* for the class if the all the members of the class have been removed.
*/
fun removeMemberInfo(className: String, memberName: String): ApiInfo? {
return apiInfo[className]?.second?.remove(memberName).also {
if (apiInfo[className]?.second?.isEmpty() == true) {
apiInfo.remove(className)
}
}
}
private fun consumeStartElement(event: StartElement) {
when (event.name.localPart) {
"api" -> apiVersion = event.getAttributeByName(QName("version")).value.toInt()
"class" -> consumeClass(event)
"field" -> consumeMember(event, "field")
"method" -> consumeMember(event, "method")
}
}
private fun consumeClass(event: StartElement) {
checkNotNull(apiVersion) {
"<class> element must be inside <api> element"
}
check(currentClass == null) {
"<class> elements cannot be nested"
}
val (name, versions) = event.parseAttrs()
check(!apiInfo.containsKey(name)) {
"Duplicate class entry: $name"
}
val apiInfo = apiInfos.getOrPut(versions) {
createApiInfo(versions)
}
this.apiInfo[name] = apiInfo to HashMap()
this.currentClass = name
}
private fun consumeMember(event: StartElement, memberType: String) {
val (name, versions) = event.parseAttrs()
val currentClass = checkNotNull(currentClass) {
"<${memberType}> element must be inside <class> element"
}
check(!apiInfo.containsKey(name)) {
"Duplicate $memberType entry in class $currentClass: $name"
}
val (_, members) = apiInfo[currentClass]!!
val existing = members.put(name, createApiInfo(versions))
check(existing == null) {
"Duplicate $memberType entry in class $currentClass: $name"
}
}
private fun consumeEndElement(element: EndElement) {
when (element.name.localPart) {
"api" -> apiVersion = null
"class" -> currentClass = null
}
}
private fun StartElement.parseAttrs(): Pair<String, Int> {
var name: String? = null
var since = 1
var deprecated = 0
var removed = 0
attributes.forEach { attribute ->
attribute as Attribute
when (attribute.name.localPart) {
"name" -> name = attribute.value
"since" -> since = attribute.value.toInt()
"deprecated" -> deprecated = attribute.value.toInt()
"removed" -> removed = attribute.value.toInt()
}
}
checkNotNull(name) {
"Missing name attribute"
}
// Android API versions would not exceed 255, right?
check(since in 1..255 && deprecated in 0..255 && removed in 0..255) {
"Invalid version: $since, $deprecated, $removed"
}
val version = (since shl 16) + (deprecated shl 8) + removed
return name!! to version
}
private fun createApiInfo(versions: Int): ApiInfo {
val since = (versions shr 16) and 0x000000FF
val deprecated = (versions shr 8) and 0x000000FF
val removed = versions and 0x000000FF
return ApiInfo.newInstance(since = since, deprecatedIn = deprecated, removedIn = removed)
}
}
@@ -0,0 +1,342 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.lsp.java.indexing.apiinfo
import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.testing.common.utils.findAndroidHome
import jaxp.xml.stream.XMLStreamException
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayInputStream
import java.io.File
/**
* @author Akash Yadav
*/
@RunWith(RobolectricTestRunner::class)
class ApiInfoParserTest {
val apiVersionXml by lazy {
val platformsDir = File(findAndroidHome(), "platforms")
val platformDir = platformsDir.listFiles()!!.asSequence().sortedByDescending {
// android-30, android-31, android-32, android-33, etc
it.name.substring("android-".length).toIntOrNull() ?: 0
}.first()
platformDir.resolve("data/api-versions.xml")
}
private lateinit var parser: ApiInfoParser
@Before
fun setup() {
parser = ApiInfoParser()
}
@Test
fun `test basic parse`() {
parser.parse(apiVersionXml.inputStream().buffered())
val manifestInfo = parser.getClassInfo("android/Manifest")
assertThat(manifestInfo).isNotNull()
assertThat(manifestInfo?.since).isEqualTo(1)
assertThat(manifestInfo?.deprecatedIn).isEqualTo(0)
assertThat(manifestInfo?.removedIn).isEqualTo(0)
}
@Test
fun testSingleClassParsing() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="0" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
parser.parse(inputStream)
val classInfo = parser.getClassInfo("com.example.ClassA")
assertThat(classInfo).isNotNull()
assertThat(classInfo!!.since).isEqualTo(1)
assertThat(classInfo.deprecatedIn).isEqualTo(0)
assertThat(classInfo.removedIn).isEqualTo(0)
}
@Test
fun testSingleClassWithMembersParsing() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="0" removed="0">
<field name="fieldA" since="2" deprecated="0" removed="0"/>
<method name="methodA" since="3" deprecated="0" removed="0"/>
</class>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
parser.parse(inputStream)
val classInfo = parser.getClassInfo("com.example.ClassA")
assertThat(classInfo).isNotNull()
assertThat(classInfo!!.since).isEqualTo(1)
val fieldInfo = parser.getMemberInfo("com.example.ClassA", "fieldA")
assertThat(fieldInfo).isNotNull()
assertThat(fieldInfo!!.since).isEqualTo(2)
val methodInfo = parser.getMemberInfo("com.example.ClassA", "methodA")
assertThat(methodInfo).isNotNull()
assertThat(methodInfo!!.since).isEqualTo(3)
}
@Test
fun testMultipleClassesParsing() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="0" removed="0"/>
<class name="com.example.ClassB" since="2" deprecated="0" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
parser.parse(inputStream)
val classAInfo = parser.getClassInfo("com.example.ClassA")
assertThat(classAInfo).isNotNull()
assertThat(classAInfo!!.since).isEqualTo(1)
val classBInfo = parser.getClassInfo("com.example.ClassB")
assertThat(classBInfo).isNotNull()
assertThat(classBInfo!!.since).isEqualTo(2)
}
@Test
fun testClassWithDeprecatedAndRemovedAttributes() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="30" removed="34"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
parser.parse(inputStream)
val classInfo = parser.getClassInfo("com.example.ClassA")
assertThat(classInfo).isNotNull()
assertThat(classInfo!!.since).isEqualTo(1)
assertThat(classInfo.deprecatedIn).isEqualTo(30)
assertThat(classInfo.removedIn).isEqualTo(34)
}
@Test
fun testClassWithInvalidAttributes() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="256" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
try {
parser.parse(inputStream)
assertThat(true).isFalse() // Should not reach here
} catch (e: IllegalStateException) {
assertThat(e).hasMessageThat().contains("Invalid version")
}
}
@Test
fun testMissingNameAttribute() {
val xml = """
<api version="34">
<class since="1" deprecated="0" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
try {
parser.parse(inputStream)
assertThat(true).isFalse() // Should not reach here
} catch (e: IllegalStateException) {
assertThat(e).hasMessageThat().contains("Missing name attribute")
}
}
@Test
fun testDuplicateClassEntry() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="0" removed="0"/>
<class name="com.example.ClassA" since="2" deprecated="0" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
try {
parser.parse(inputStream)
assertThat(true).isFalse() // Should not reach here
} catch (e: IllegalStateException) {
assertThat(e).hasMessageThat().contains("Duplicate class entry")
}
}
@Test
fun testDuplicateMemberEntry() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="0" removed="0">
<field name="fieldA" since="2" deprecated="0" removed="0"/>
<field name="fieldA" since="3" deprecated="0" removed="0"/>
</class>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
try {
parser.parse(inputStream)
assertThat(true).isFalse() // Should not reach here
} catch (e: IllegalStateException) {
assertThat(e).hasMessageThat().contains("Duplicate field entry in class com.example.ClassA")
}
}
@Test
fun testMultipleApiElements() {
val xml = """
<api version="33">
<class name="com.example.ClassA" since="1" deprecated="0" removed="0"/>
</api>
<api version="34">
<class name="com.example.ClassB" since="2" deprecated="0" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
val parser = ApiInfoParser()
assertThrows("The markup in the document following the root element must be well-formed.", XMLStreamException::class.java) {
parser.parse(inputStream)
}
}
@Test
fun testNestedClassElements() {
val xml = """
<api version="34">
<class name="com.example.OuterClass" since="1" deprecated="0" removed="0">
<class name="com.example.OuterClass.InnerClass" since="2" deprecated="0" removed="0"/>
</class>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
assertThrows("<class> elements cannot be nested", IllegalStateException::class.java) {
parser.parse(inputStream)
}
}
@Test
fun testBoundaryAttributeValues() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="255" removed="0"/>
<class name="com.example.ClassB" since="1" deprecated="0" removed="255"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
parser.parse(inputStream)
val classAInfo = parser.getClassInfo("com.example.ClassA")
assertThat(classAInfo).isNotNull()
assertThat(classAInfo!!.deprecatedIn).isEqualTo(255)
val classBInfo = parser.getClassInfo("com.example.ClassB")
assertThat(classBInfo).isNotNull()
assertThat(classBInfo!!.removedIn).isEqualTo(255)
}
@Test
fun testOutOfBoundaryAttributeValues() {
val xml = """
<api version="34">
<class name="com.example.ClassA" since="1" deprecated="256" removed="0"/>
<class name="com.example.ClassB" since="1" deprecated="0" removed="256"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
assertThrows("Invalid version: 1, 256, 0", IllegalStateException::class.java) {
parser.parse(inputStream)
}
}
@Test
fun testUnexpectedElements() {
val xml = """
<api version="34">
<unexpectedElement name="unexpected" since="1" deprecated="0" removed="0"/>
<class name="com.example.ClassA" since="1" deprecated="0" removed="0"/>
</api>
""".trimIndent()
val inputStream = ByteArrayInputStream(xml.toByteArray())
parser.parse(inputStream)
val classInfo = parser.getClassInfo("com.example.ClassA")
assertThat(classInfo).isNotNull()
assertThat(classInfo!!.since).isEqualTo(1)
}
@Test
fun testEmptyXmlFile() {
val xml = ""
val inputStream = ByteArrayInputStream(xml.toByteArray())
parser.parse(inputStream)
}
@Test
fun testLargeInputFile() {
val xmlBuilder = StringBuilder()
xmlBuilder.append("<api version=\"34\">")
for (i in 1..1000) {
xmlBuilder.append("<class name=\"com.example.Class$i\" since=\"${(i % 254) + 1}\" />")
}
xmlBuilder.append("</api>")
val inputStream = ByteArrayInputStream(xmlBuilder.toString().toByteArray())
val parser = ApiInfoParser()
parser.parse(inputStream)
for (i in 1..1000) {
val classInfo = parser.getClassInfo("com.example.Class$i")
assertThat(classInfo).isNotNull()
assertThat(classInfo!!.since).isEqualTo((i % 254) + 1)
assertThat(classInfo.deprecatedIn).isEqualTo(0)
assertThat(classInfo.removedIn).isEqualTo(0)
}
}
}
@@ -0,0 +1,53 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.testing.common.utils
/**
* Information about the operating system.
*
* @author Akash Yadav
*/
object OperatingSystem {
/**
* The name of the operating system.
*/
val OS = System.getProperty("os.name")
/**
* True if the operating system is Windows.
*/
var IS_WINDOWS: Boolean = OS.indexOf("win") >= 0
/**
* True if the operating system is Mac.
*/
var IS_MAC: Boolean = OS.indexOf("mac") >= 0
/**
* True if the operating system is Unix.
*/
var IS_UNIX: Boolean = OS.indexOf("nix") >= 0 ||
OS.indexOf("nux") >= 0 ||
OS.indexOf("aix") > 0
/**
* True if the operating system is Solaris.
*/
var IS_SOLARIS: Boolean = OS.indexOf("sunos") >= 0
}
@@ -15,7 +15,7 @@
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.testing.tooling
package com.itsaky.androidide.testing.common.utils
fun findAndroidHome(): String {
var androidHome = System.getenv("ANDROID_HOME")
@@ -28,11 +28,11 @@ fun findAndroidHome(): String {
return androidHome
}
val os = System.getProperty("os.name")
val home = System.getProperty("user.home")
return if (os.contains("Linux")) {
"$home/Android/Sdk"
} else {
"$home\\AppData\\Local\\Android\\Sdk"
return when {
OperatingSystem.IS_UNIX -> "$home/Android/Sdk"
OperatingSystem.IS_MAC -> "$home/Library/Android/sdk"
OperatingSystem.IS_WINDOWS -> "$home\\AppData\\Local\\Android\\Sdk"
else -> throw IllegalStateException("Unsupported operating system")
}
}
@@ -113,7 +113,7 @@ object ToolingApiTestLauncher {
)
val builder = ProcessBuilder(cmdLine)
val androidHome = findAndroidHome()
val androidHome = com.itsaky.androidide.testing.common.utils.findAndroidHome()
builder.environment()["ANDROID_SDK_ROOT"] = androidHome
builder.environment()["ANDROID_HOME"] = androidHome
@@ -124,7 +124,8 @@ object ToolingApiTestLauncher {
proc.onExit().whenComplete { process, error ->
if (process != null) {
println(
"[ToolingApiTestLauncher] Tooling API server process finished with exit code: ${process.exitValue()}")
"[ToolingApiTestLauncher] Tooling API server process finished with exit code: ${process.exitValue()}"
)
}
if (error != null) {
println("[ToolingApiTestLauncher] Tooling API server process error")
@@ -134,8 +135,10 @@ object ToolingApiTestLauncher {
Thread(Reader(proc.errorStream, params.log)).start()
val launcher =
ToolingApiLauncher.newClientLauncher(params.client, proc.inputStream,
proc.outputStream)
ToolingApiLauncher.newClientLauncher(
params.client, proc.inputStream,
proc.outputStream
)
launcher.startListening()