Implement Android resource reference folding
#KT-15451 Fixed
This commit is contained in:
@@ -21,5 +21,6 @@
|
||||
<orderEntry type="module" module-name="tests-common" scope="TEST" />
|
||||
<orderEntry type="module" module-name="idea-core" />
|
||||
<orderEntry type="module" module-name="lint-idea" scope="TEST" />
|
||||
<orderEntry type="library" name="uast-java" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* Copyright 2010-2017 JetBrains s.r.o.
|
||||
*
|
||||
* 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 org.jetbrains.kotlin.android.folding
|
||||
|
||||
import com.android.SdkConstants.*
|
||||
import com.android.ide.common.resources.configuration.FolderConfiguration
|
||||
import com.android.ide.common.resources.configuration.LocaleQualifier
|
||||
import com.android.resources.ResourceType
|
||||
import com.android.tools.idea.folding.AndroidFoldingSettings
|
||||
import com.android.tools.idea.rendering.AppResourceRepository
|
||||
import com.android.tools.idea.rendering.LocalResourceRepository
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.lang.folding.FoldingBuilderEx
|
||||
import com.intellij.lang.folding.FoldingDescriptor
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.ServiceManager
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.module.ModuleUtilCore
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.psi.PsiClass
|
||||
import com.intellij.psi.PsiClassOwner
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.impl.source.SourceTreeToPsiMap
|
||||
import org.jetbrains.kotlin.psi.KtFile
|
||||
import org.jetbrains.uast.*
|
||||
import org.jetbrains.uast.visitor.AbstractUastVisitor
|
||||
import java.util.regex.Pattern
|
||||
|
||||
|
||||
class ResourceFoldingBuilder : FoldingBuilderEx() {
|
||||
|
||||
companion object {
|
||||
// See lint's StringFormatDetector
|
||||
private val FORMAT = Pattern.compile("%(\\d+\\$)?([-+#, 0(<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])")
|
||||
private val FOLD_MAX_LENGTH = 60
|
||||
private val FORCE_PROJECT_RESOURCE_LOADING = true
|
||||
private val UNIT_TEST_MODE: Boolean = ApplicationManager.getApplication().isUnitTestMode
|
||||
private val RESOURCE_TYPES = listOf(ResourceType.STRING,
|
||||
ResourceType.DIMEN,
|
||||
ResourceType.INTEGER,
|
||||
ResourceType.PLURALS)
|
||||
}
|
||||
|
||||
private val isFoldingEnabled = AndroidFoldingSettings.getInstance().isCollapseAndroidStrings
|
||||
|
||||
override fun getPlaceholderText(node: ASTNode): String? {
|
||||
|
||||
tailrec fun UElement.unwrapReferenceAndGetValue(resources: LocalResourceRepository): String? = when (this) {
|
||||
is UQualifiedReferenceExpression -> selector.unwrapReferenceAndGetValue(resources)
|
||||
is UCallExpression -> (valueArguments.firstOrNull() as? UReferenceExpression)?.getAndroidResourceValue(resources, this)
|
||||
else -> (this as? UReferenceExpression)?.getAndroidResourceValue(resources)
|
||||
}
|
||||
|
||||
val element = SourceTreeToPsiMap.treeElementToPsi(node) ?: return null
|
||||
val appResources = getAppResources(element) ?: return null
|
||||
val uastContext = ServiceManager.getService(element.project, UastContext::class.java) ?: return null
|
||||
return uastContext.convertElement(element, null, null)?.unwrapReferenceAndGetValue(appResources)
|
||||
}
|
||||
|
||||
override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array<FoldingDescriptor> {
|
||||
if (root !is KtFile || quick && !UNIT_TEST_MODE || !isFoldingEnabled) {
|
||||
return emptyArray()
|
||||
}
|
||||
|
||||
val file = root.toUElement()
|
||||
val result = arrayListOf<FoldingDescriptor>()
|
||||
file?.accept(object : AbstractUastVisitor() {
|
||||
override fun visitSimpleNameReferenceExpression(node: USimpleNameReferenceExpression): Boolean {
|
||||
node.getFoldingDescriptor()?.let { result.add(it) }
|
||||
return super.visitSimpleNameReferenceExpression(node)
|
||||
}
|
||||
})
|
||||
|
||||
return result.toTypedArray()
|
||||
}
|
||||
|
||||
override fun isCollapsedByDefault(node: ASTNode): Boolean = isFoldingEnabled
|
||||
|
||||
private fun UReferenceExpression.getFoldingDescriptor(): FoldingDescriptor? {
|
||||
val resolved = resolve() ?: return null
|
||||
val resourceType = resolved.getAndroidResourceType() ?: return null
|
||||
if (resourceType !in RESOURCE_TYPES) return null
|
||||
|
||||
fun UElement.createFoldingDescriptor() = psi?.let { psi ->
|
||||
val dependencies: Set<PsiElement> = setOf(psi)
|
||||
FoldingDescriptor(psi.node, psi.textRange, null, dependencies)
|
||||
}
|
||||
|
||||
val element = getOutermostQualified() ?: this
|
||||
|
||||
(element.containingElement as? UCallExpression)?.run {
|
||||
if (isFoldableGetResourceValueCall()) {
|
||||
return getOutermostQualified()?.createFoldingDescriptor() ?: createFoldingDescriptor()
|
||||
}
|
||||
}
|
||||
|
||||
return element.createFoldingDescriptor()
|
||||
}
|
||||
|
||||
private fun UCallExpression.isFoldableGetResourceValueCall() =
|
||||
methodName == "getString" ||
|
||||
methodName == "getText" ||
|
||||
methodName == "getInteger" ||
|
||||
methodName?.startsWith("getDimension") ?: false ||
|
||||
methodName?.startsWith("getQuantityString") ?: false
|
||||
|
||||
private fun PsiElement.getAndroidResourceType(): ResourceType? {
|
||||
val elementType = parent as? PsiClass ?: return null
|
||||
val elementPackage = elementType.parent as? PsiClass ?: return null
|
||||
if (R_CLASS != elementPackage.name) return null
|
||||
if (elementPackage.qualifiedName != "$ANDROID_PKG.$R_CLASS") {
|
||||
return ResourceType.getEnum(elementType.name)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun UReferenceExpression.getAndroidResourceValue(resources: LocalResourceRepository, call: UCallExpression? = null): String? {
|
||||
val resourceType = resolve()?.getAndroidResourceType() ?: return null
|
||||
val referenceConfig = FolderConfiguration().apply { localeQualifier = LocaleQualifier("xx") }
|
||||
val key = resolvedName
|
||||
val resourceValue = resources.getConfiguredValue(resourceType, key, referenceConfig)?.value ?: return null
|
||||
val text = if (call != null) formatArguments(call, resourceValue) else resourceValue
|
||||
|
||||
if (resourceType == ResourceType.PLURALS && text.startsWith(STRING_PREFIX)) {
|
||||
resources.getConfiguredValue(ResourceType.STRING, text.substring(STRING_PREFIX.length), referenceConfig)?.value?.let {
|
||||
return '"' + StringUtil.shortenTextWithEllipsis(it, FOLD_MAX_LENGTH - 2, 0) + '"'
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType == ResourceType.STRING) {
|
||||
return '"' + StringUtil.shortenTextWithEllipsis(text, FOLD_MAX_LENGTH - 2, 0) + '"'
|
||||
}
|
||||
else if (text.length <= 1) {
|
||||
// Don't just inline empty or one-character replacements: they can't be expanded by a mouse click
|
||||
// so are hard to use without knowing about the folding keyboard shortcut to toggle folding.
|
||||
// This is similar to how IntelliJ 14 handles call parameters
|
||||
return key + ": " + text
|
||||
}
|
||||
|
||||
return StringUtil.shortenTextWithEllipsis(text, FOLD_MAX_LENGTH, 0)
|
||||
}
|
||||
|
||||
// Converted from com.android.tools.idea.folding.InlinedResource#insertArguments
|
||||
private fun formatArguments(callExpression: UCallExpression, formatString: String): String {
|
||||
if (!formatString.contains('%')) {
|
||||
return formatString
|
||||
}
|
||||
|
||||
val args = callExpression.valueArguments
|
||||
if (args.isEmpty() || !args.first().isPsiValid) {
|
||||
return formatString
|
||||
}
|
||||
|
||||
val matcher = FORMAT.matcher(formatString)
|
||||
var index = 0
|
||||
var prevIndex = 0
|
||||
var nextNumber = 1
|
||||
var start = 0
|
||||
val sb = StringBuilder(2 * formatString.length)
|
||||
while (true) {
|
||||
if (matcher.find(index)) {
|
||||
if ("%" == matcher.group(6)) {
|
||||
index = matcher.end()
|
||||
continue
|
||||
}
|
||||
val matchStart = matcher.start()
|
||||
// Make sure this is not an escaped '%'
|
||||
while (prevIndex < matchStart) {
|
||||
val c = formatString[prevIndex]
|
||||
if (c == '\\') {
|
||||
prevIndex++
|
||||
}
|
||||
prevIndex++
|
||||
}
|
||||
if (prevIndex > matchStart) {
|
||||
// We're in an escape, ignore this result
|
||||
index = prevIndex
|
||||
continue
|
||||
}
|
||||
|
||||
index = matcher.end()
|
||||
|
||||
// Shouldn't throw a number format exception since we've already
|
||||
// matched the pattern in the regexp
|
||||
val number: Int
|
||||
var numberString: String? = matcher.group(1)
|
||||
if (numberString != null) {
|
||||
// Strip off trailing $
|
||||
numberString = numberString.substring(0, numberString.length - 1)
|
||||
number = Integer.parseInt(numberString)
|
||||
nextNumber = number + 1
|
||||
}
|
||||
else {
|
||||
number = nextNumber++
|
||||
}
|
||||
|
||||
if (number > 0 && number < args.size) {
|
||||
val argExpression = args[number]
|
||||
var value: Any? = argExpression.evaluate()
|
||||
|
||||
if (value == null) {
|
||||
value = args[number].asSourceString()
|
||||
}
|
||||
|
||||
for (i in start..matchStart - 1) {
|
||||
sb.append(formatString[i])
|
||||
}
|
||||
|
||||
sb.append("{")
|
||||
sb.append(value)
|
||||
sb.append('}')
|
||||
start = index
|
||||
}
|
||||
}
|
||||
else {
|
||||
var i = start
|
||||
val n = formatString.length
|
||||
while (i < n) {
|
||||
sb.append(formatString[i])
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun getAppResources(element: PsiElement): LocalResourceRepository? = ModuleUtilCore.findModuleForPsiElement(element)?.let {
|
||||
AppResourceRepository.getAppResources(it, FORCE_PROJECT_RESOURCE_LOADING)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2010-2017 JetBrains s.r.o.
|
||||
*
|
||||
* 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 org.jetbrains.kotlin.android.folding
|
||||
|
||||
import com.android.SdkConstants
|
||||
import org.jetbrains.kotlin.android.KotlinAndroidTestCase
|
||||
|
||||
|
||||
abstract class AbstractAndroidResourceFoldingTest : KotlinAndroidTestCase() {
|
||||
|
||||
fun doTest(path: String) {
|
||||
myFixture.copyFileToProject("values.xml", "res/values/values.xml")
|
||||
myFixture.copyFileToProject("R.java", "gen/com/myapp/R.java")
|
||||
myFixture.testFoldingWithCollapseStatus(path, "${myFixture.tempDirPath}/src/main.kt")
|
||||
}
|
||||
|
||||
override fun createManifest() {
|
||||
myFixture.copyFileToProject("idea/testData/android/AndroidManifest.xml", SdkConstants.FN_ANDROID_MANIFEST_XML)
|
||||
}
|
||||
|
||||
override fun getTestDataPath() = "idea/testData/android/folding"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2010-2017 JetBrains s.r.o.
|
||||
*
|
||||
* 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 org.jetbrains.kotlin.android.folding;
|
||||
|
||||
import com.intellij.testFramework.TestDataPath;
|
||||
import org.jetbrains.kotlin.test.JUnit3RunnerWithInners;
|
||||
import org.jetbrains.kotlin.test.KotlinTestUtils;
|
||||
import org.jetbrains.kotlin.test.TargetBackend;
|
||||
import org.jetbrains.kotlin.test.TestMetadata;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** This class is generated by {@link org.jetbrains.kotlin.generators.tests.TestsPackage}. DO NOT MODIFY MANUALLY */
|
||||
@SuppressWarnings("all")
|
||||
@TestMetadata("idea/testData/android/folding")
|
||||
@TestDataPath("$PROJECT_ROOT")
|
||||
@RunWith(JUnit3RunnerWithInners.class)
|
||||
public class AndroidResourceFoldingTestGenerated extends AbstractAndroidResourceFoldingTest {
|
||||
public void testAllFilesPresentInFolding() throws Exception {
|
||||
KotlinTestUtils.assertAllTestsPresentByMetadata(this.getClass(), new File("idea/testData/android/folding"), Pattern.compile("^(.+)\\.kt$"), TargetBackend.ANY, true);
|
||||
}
|
||||
|
||||
@TestMetadata("dimensions.kt")
|
||||
public void testDimensions() throws Exception {
|
||||
String fileName = KotlinTestUtils.navigationMetadata("idea/testData/android/folding/dimensions.kt");
|
||||
doTest(fileName);
|
||||
}
|
||||
|
||||
@TestMetadata("getString.kt")
|
||||
public void testGetString() throws Exception {
|
||||
String fileName = KotlinTestUtils.navigationMetadata("idea/testData/android/folding/getString.kt");
|
||||
doTest(fileName);
|
||||
}
|
||||
|
||||
@TestMetadata("plurals.kt")
|
||||
public void testPlurals() throws Exception {
|
||||
String fileName = KotlinTestUtils.navigationMetadata("idea/testData/android/folding/plurals.kt");
|
||||
doTest(fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user