Implement Android resource reference folding

#KT-15451 Fixed
This commit is contained in:
Vyacheslav Gerasimov
2017-02-07 20:29:54 +03:00
parent 39010ab847
commit a907ec92b5
12 changed files with 482 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="4u7">
<words>
<w>foldable</w>
</words>
</dictionary>
</component>
@@ -23,6 +23,7 @@ import org.jetbrains.kotlin.addImport.AbstractAddImportTest
import org.jetbrains.kotlin.allopen.AbstractBytecodeListingTestForAllOpen
import org.jetbrains.kotlin.android.*
import org.jetbrains.kotlin.android.configure.AbstractConfigureProjectTest
import org.jetbrains.kotlin.android.folding.AbstractAndroidResourceFoldingTest
import org.jetbrains.kotlin.android.intentions.AbstractAndroidIntentionTest
import org.jetbrains.kotlin.android.intentions.AbstractAndroidResourceIntentionTest
import org.jetbrains.kotlin.android.lint.AbstractKotlinLintTest
@@ -1222,6 +1223,10 @@ fun main(args: Array<String>) {
testClass<AbstractAndroidIntentionTest> {
model("android/intentions", pattern = "^([\\w\\-_]+)\\.kt$")
}
testClass<AbstractAndroidResourceFoldingTest> {
model("android/folding")
}
}
testGroup("plugins/plugins-tests/tests", "plugins/android-extensions/android-extensions-jps/testData") {
+1
View File
@@ -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)
}
}
@@ -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"
}
@@ -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);
}
}
+2
View File
@@ -27,6 +27,8 @@
<codeInsight.unresolvedReferenceQuickFixProvider
implementation="org.jetbrains.kotlin.android.inspection.KotlinAndroidResourceQuickFixProvider"/>
<lang.foldingBuilder language="kotlin" implementationClass="org.jetbrains.kotlin.android.folding.ResourceFoldingBuilder" />
</extensions>
<extensions defaultExtensionNs="org.jetbrains.kotlin">
+34
View File
@@ -0,0 +1,34 @@
package com.myapp;
public final class R {
public static final class string {
public static final int app_title=0x7f060022;
public static final int compex_format_string=0x7f060023;
public static final int empty_string=0x7f060024;
public static final int format_string=0x7f060025;
public static final int invalid_format=0x7f060026;
public static final int long_string=0x7f060027;
public static final int plural_few=0x7f060028;
public static final int plural_one=0x7f060029;
public static final int plural_other=0x7f06002a;
public static final int positive_button_text=0x7f06002b;
public static final int string1=0x7f06002c;
public static final int string2=0x7f06002d;
public static final int string3=0x7f06002e;
}
public static final class integer {
public static final int some_int=0x7f0c0003;
public static final int max_action_buttons=0x7f0c0004;
}
public static final class plurals {
public static final int first=0x7f0d0000;
public static final int second=0x7f0d0001;
}
public static final class dimen {
public static final int action_bar_default_height=0x7f07004f;
public static final int action_bar_icon_vertical_padding=0x7f070050;
public static final int mydimen1=0x7f07005b;
public static final int mydimen2=0x7f07005c;
public static final int mydimen3=0x7f07005d;
}
}
+19
View File
@@ -0,0 +1,19 @@
package com.myapp
import <fold text='...' expand='false'>android.app.Activity
import android.app.AlertDialog</fold>
class MyActivity : Activity() <fold text='{...}' expand='true'>{
fun test() <fold text='{...}' expand='true'>{
val dialog = AlertDialog.Builder(this)
.setView(null)
.setPositiveButton(<fold text='"Yes"' expand='false'>R.string.positive_button_text</fold>, null)
.create()
val dimension = <fold text='56dip' expand='false'>resources.getDimension(R.dimen.action_bar_default_height)</fold>
val dimension2 = <fold text='4dip' expand='false'>resources.getDimensionPixelOffset(R.dimen.action_bar_icon_vertical_padding)</fold>
val dimension3 = <fold text='3dip' expand='false'>resources.getDimensionPixelSize(R.dimen.mydimen3)</fold>
val strings = intArrayOf(<fold text='"1111"' expand='false'>R.string.string1</fold>, <fold text='"2222"' expand='false'>R.string.string2</fold>, <fold text='"3333"' expand='false'>R.string.string3</fold>)
val dimensions = intArrayOf(<fold text='1dip' expand='false'>R.dimen.mydimen1</fold>, <fold text='2dip' expand='false'>R.dimen.mydimen2</fold>)
val maxButtons = <fold text='max_action_buttons: 2' expand='false'>resources.getInteger(R.integer.max_action_buttons)</fold>
}</fold>
}</fold>
+27
View File
@@ -0,0 +1,27 @@
package com.myapp
import <fold text='...' expand='false'>android.content.Context
import com.myapp.R.string.*;</fold>
fun Context.getAppTitle() = <fold text='"My Cool Application"' expand='false'>getString(R.string.app_title)</fold>
fun getAppTitle(context: Context) <fold text='{...}' expand='true'>{
return <fold text='"My Cool Application"' expand='false'>context.getString(app_title)</fold>
}</fold>
fun getLongString(context: Context) = <fold text='"Some very very long string which should be trimmed. Lor..."' expand='false'>context.getString(R.string.long_string)</fold>
fun bar(text: String) = text
fun foo(context: Context) <fold text='{...}' expand='true'>{
val name = "Vasya"
val otherName = context.getString(R.string.no_resource)
val cancel = context.getString(android.R.string.cancel)
val text = <fold text='"Hello {Vasya}!"' expand='false'>context.getString(R.string.format_string, name)</fold>
val text = <fold text='"Hello {otherName}!"' expand='false'>context.getString(R.string.format_string, otherName)</fold>
val emptyString = <fold text='""' expand='false'>context.getString(R.string.empty_string)</fold>
val someInt = <fold text='some_int: 8' expand='false'>context.resources.getInteger(R.integer.some_int)</fold>
context.getString(R.string.no_resource)
bar(<fold text='"Third: {333} Repeated: {333} First: {111} Second: {222}"' expand='false'>context.getResources().getString(R.string.compex_format_string, "111", "222", "333")</fold>)
val invalid = with(context) { getString(<fold text='"Escaped: %s First: %1$s Invalid: %20$s"' expand='false'>R.string.invalid_format</fold>, name, otherName) }
}</fold>
+9
View File
@@ -0,0 +1,9 @@
package com.myapp
import android.content.Context
fun Context.testPlurals(quantity: Int) <fold text='{...}' expand='true'>{
val plural1 = <fold text='"Reference One"' expand='false'>resources.getQuantityString(R.plurals.first, quantity)</fold>
val plural2 = <fold text='Quantity One' expand='false'>this.getResources().getQuantityString(R.plurals.second, quantity)</fold>
}</fold>
+39
View File
@@ -0,0 +1,39 @@
<resources>
<string name="app_name">ResourceFolding</string>
<string name="app_title">My Cool Application</string>
<string name="long_string">Some very very long string which should be trimmed. Lorem ipsum dolor sit amet.</string>
<string name="format_string">Hello %s!</string>
<string name="empty_string" />
<string name="compex_format_string" formatted="false">Third: %3$s Repeated: %3$s First: %1$s Second: %2$d</string>
<string name="invalid_format" formatted="false">Escaped: \%s First: %1$s Invalid: %20$s</string>
<string name="plural_one">Reference One</string>
<string name="plural_few">Reference Few</string>
<string name="plural_other">Reference Other</string>
<string name="positive_button_text">Yes</string>
<string name="string1">1111</string>
<string name="string2">2222</string>
<string name="string3">3333</string>
<integer name="some_int">8</integer>
<integer name="max_action_buttons">2</integer>
<plurals name="first">
<item quantity="one">@string/plural_one</item>
<item quantity="few">@string/plural_few</item>
<item quantity="other">@string/plural_other</item>
</plurals>
<plurals name="second">
<item quantity="one">Quantity One</item>
<item quantity="few">Quantity Few</item>
</plurals>
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="action_bar_default_height">56dip</dimen>
<dimen name="action_bar_icon_vertical_padding">4dip</dimen>
<dimen name="mydimen3">3dip</dimen>
<dimen name="mydimen1">1dip</dimen>
<dimen name="mydimen2">2dip</dimen>
</resources>