From 255d8e9c27ee8e5e0776056673489b386e4e9e6f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 28 May 2024 00:01:50 +0530 Subject: [PATCH] fix(lsp/java): add parser for parsing api-versions.xml --- lsp/java/build.gradle.kts | 4 +- .../lsp/java/indexing/JavaJarModelBuilder.kt | 2 + .../lsp/java/indexing/apiinfo/ApiInfo.kt | 9 +- .../java/indexing/apiinfo/ApiInfoParser.kt | 184 ++++++++++ .../indexing/apiinfo/ApiInfoParserTest.kt | 342 ++++++++++++++++++ .../testing/common/utils/OperatingSystem.kt | 53 +++ .../testing/common/utils/utilsExt.kt} | 12 +- .../testing/tooling/ToolingApiTestLauncher.kt | 11 +- 8 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParser.kt create mode 100644 lsp/java/src/test/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParserTest.kt create mode 100644 testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/OperatingSystem.kt rename testing/{tooling/src/main/java/com/itsaky/androidide/testing/tooling/utils.kt => common/src/main/java/com/itsaky/androidide/testing/common/utils/utilsExt.kt} (76%) diff --git a/lsp/java/build.gradle.kts b/lsp/java/build.gradle.kts index b297a1119..51695c329 100644 --- a/lsp/java/build.gradle.kts +++ b/lsp/java/build.gradle.kts @@ -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) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/JavaJarModelBuilder.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/JavaJarModelBuilder.kt index 20284ba91..f0c5459d4 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/JavaJarModelBuilder.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/JavaJarModelBuilder.kt @@ -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> { val types = mutableListOf>() consumeTypes { types.add(it) } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfo.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfo.kt index 737bc860c..04b7e968d 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfo.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfo.kt @@ -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) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParser.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParser.kt new file mode 100644 index 000000000..cdcad65c7 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParser.kt @@ -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 . + */ + +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() + private val apiInfo = HashMap>>() + + 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) { + " element must be inside element" + } + + check(currentClass == null) { + " 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 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 { + 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) + } +} \ No newline at end of file diff --git a/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParserTest.kt b/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParserTest.kt new file mode 100644 index 000000000..85b176b29 --- /dev/null +++ b/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/indexing/apiinfo/ApiInfoParserTest.kt @@ -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 . + */ + +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 = """ + + + + """.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 = """ + + + + + + + """.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 = """ + + + + + """.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 = """ + + + + """.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 = """ + + + + """.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 = """ + + + + """.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 = """ + + + + + """.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 = """ + + + + + + + """.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 = """ + + + + + + + """.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 = """ + + + + + + """.trimIndent() + + val inputStream = ByteArrayInputStream(xml.toByteArray()) + + assertThrows(" elements cannot be nested", IllegalStateException::class.java) { + parser.parse(inputStream) + } + } + + @Test + fun testBoundaryAttributeValues() { + val xml = """ + + + + + """.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 = """ + + + + + """.trimIndent() + val inputStream = ByteArrayInputStream(xml.toByteArray()) + assertThrows("Invalid version: 1, 256, 0", IllegalStateException::class.java) { + parser.parse(inputStream) + } + } + + @Test + fun testUnexpectedElements() { + val xml = """ + + + + + """.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("") + for (i in 1..1000) { + xmlBuilder.append("") + } + xmlBuilder.append("") + 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) + } + } +} \ No newline at end of file diff --git a/testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/OperatingSystem.kt b/testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/OperatingSystem.kt new file mode 100644 index 000000000..8ca542629 --- /dev/null +++ b/testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/OperatingSystem.kt @@ -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 . + */ + +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 +} \ No newline at end of file diff --git a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/utils.kt b/testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/utilsExt.kt similarity index 76% rename from testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/utils.kt rename to testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/utilsExt.kt index 68b5a7faf..ff46333a7 100644 --- a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/utils.kt +++ b/testing/common/src/main/java/com/itsaky/androidide/testing/common/utils/utilsExt.kt @@ -15,7 +15,7 @@ * along with AndroidIDE. If not, see . */ -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") } } \ No newline at end of file diff --git a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt index 8612d52c0..76d482a05 100644 --- a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt +++ b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt @@ -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()