diff --git a/.idea/dictionaries/4u7.xml b/.idea/dictionaries/4u7.xml new file mode 100644 index 00000000000..57de8025848 --- /dev/null +++ b/.idea/dictionaries/4u7.xml @@ -0,0 +1,7 @@ + + + + foldable + + + \ No newline at end of file diff --git a/generators/src/org/jetbrains/kotlin/generators/tests/GenerateTests.kt b/generators/src/org/jetbrains/kotlin/generators/tests/GenerateTests.kt index a7f99bc3966..5648d4089d1 100755 --- a/generators/src/org/jetbrains/kotlin/generators/tests/GenerateTests.kt +++ b/generators/src/org/jetbrains/kotlin/generators/tests/GenerateTests.kt @@ -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) { testClass { model("android/intentions", pattern = "^([\\w\\-_]+)\\.kt$") } + + testClass { + model("android/folding") + } } testGroup("plugins/plugins-tests/tests", "plugins/android-extensions/android-extensions-jps/testData") { diff --git a/idea/idea-android/idea-android.iml b/idea/idea-android/idea-android.iml index a0388a5a205..fac324c777e 100644 --- a/idea/idea-android/idea-android.iml +++ b/idea/idea-android/idea-android.iml @@ -21,5 +21,6 @@ + \ No newline at end of file diff --git a/idea/idea-android/src/org/jetbrains/kotlin/android/folding/ResourceFoldingBuilder.kt b/idea/idea-android/src/org/jetbrains/kotlin/android/folding/ResourceFoldingBuilder.kt new file mode 100644 index 00000000000..1aeaad546b3 --- /dev/null +++ b/idea/idea-android/src/org/jetbrains/kotlin/android/folding/ResourceFoldingBuilder.kt @@ -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 { + if (root !is KtFile || quick && !UNIT_TEST_MODE || !isFoldingEnabled) { + return emptyArray() + } + + val file = root.toUElement() + val result = arrayListOf() + 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 = 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) + } +} diff --git a/idea/idea-android/tests/org/jetbrains/kotlin/android/folding/AbstractAndroidFoldingTest.kt b/idea/idea-android/tests/org/jetbrains/kotlin/android/folding/AbstractAndroidFoldingTest.kt new file mode 100644 index 00000000000..e27fbde9e2b --- /dev/null +++ b/idea/idea-android/tests/org/jetbrains/kotlin/android/folding/AbstractAndroidFoldingTest.kt @@ -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" +} \ No newline at end of file diff --git a/idea/idea-android/tests/org/jetbrains/kotlin/android/folding/AndroidResourceFoldingTestGenerated.java b/idea/idea-android/tests/org/jetbrains/kotlin/android/folding/AndroidResourceFoldingTestGenerated.java new file mode 100644 index 00000000000..2478cf7d1a5 --- /dev/null +++ b/idea/idea-android/tests/org/jetbrains/kotlin/android/folding/AndroidResourceFoldingTestGenerated.java @@ -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); + } +} diff --git a/idea/src/META-INF/android.xml b/idea/src/META-INF/android.xml index 19692f6d7f8..6d871388068 100644 --- a/idea/src/META-INF/android.xml +++ b/idea/src/META-INF/android.xml @@ -27,6 +27,8 @@ + + diff --git a/idea/testData/android/folding/R.java b/idea/testData/android/folding/R.java new file mode 100644 index 00000000000..63c3d41c701 --- /dev/null +++ b/idea/testData/android/folding/R.java @@ -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; + } +} \ No newline at end of file diff --git a/idea/testData/android/folding/dimensions.kt b/idea/testData/android/folding/dimensions.kt new file mode 100644 index 00000000000..5c8a3956137 --- /dev/null +++ b/idea/testData/android/folding/dimensions.kt @@ -0,0 +1,19 @@ +package com.myapp + +import android.app.Activity +import android.app.AlertDialog + +class MyActivity : Activity() { + fun test() { + val dialog = AlertDialog.Builder(this) + .setView(null) + .setPositiveButton(R.string.positive_button_text, null) + .create() + val dimension = resources.getDimension(R.dimen.action_bar_default_height) + val dimension2 = resources.getDimensionPixelOffset(R.dimen.action_bar_icon_vertical_padding) + val dimension3 = resources.getDimensionPixelSize(R.dimen.mydimen3) + val strings = intArrayOf(R.string.string1, R.string.string2, R.string.string3) + val dimensions = intArrayOf(R.dimen.mydimen1, R.dimen.mydimen2) + val maxButtons = resources.getInteger(R.integer.max_action_buttons) + } +} diff --git a/idea/testData/android/folding/getString.kt b/idea/testData/android/folding/getString.kt new file mode 100644 index 00000000000..f717dc01d05 --- /dev/null +++ b/idea/testData/android/folding/getString.kt @@ -0,0 +1,27 @@ +package com.myapp + +import android.content.Context +import com.myapp.R.string.*; + +fun Context.getAppTitle() = getString(R.string.app_title) + +fun getAppTitle(context: Context) { + return context.getString(app_title) +} + +fun getLongString(context: Context) = context.getString(R.string.long_string) + +fun bar(text: String) = text + +fun foo(context: Context) { + val name = "Vasya" + val otherName = context.getString(R.string.no_resource) + val cancel = context.getString(android.R.string.cancel) + val text = context.getString(R.string.format_string, name) + val text = context.getString(R.string.format_string, otherName) + val emptyString = context.getString(R.string.empty_string) + val someInt = context.resources.getInteger(R.integer.some_int) + context.getString(R.string.no_resource) + bar(context.getResources().getString(R.string.compex_format_string, "111", "222", "333")) + val invalid = with(context) { getString(R.string.invalid_format, name, otherName) } +} diff --git a/idea/testData/android/folding/plurals.kt b/idea/testData/android/folding/plurals.kt new file mode 100644 index 00000000000..3f18d84d847 --- /dev/null +++ b/idea/testData/android/folding/plurals.kt @@ -0,0 +1,9 @@ +package com.myapp + +import android.content.Context + +fun Context.testPlurals(quantity: Int) { + val plural1 = resources.getQuantityString(R.plurals.first, quantity) + val plural2 = this.getResources().getQuantityString(R.plurals.second, quantity) +} + diff --git a/idea/testData/android/folding/values.xml b/idea/testData/android/folding/values.xml new file mode 100644 index 00000000000..bcd5571bb3e --- /dev/null +++ b/idea/testData/android/folding/values.xml @@ -0,0 +1,39 @@ + + ResourceFolding + My Cool Application + Some very very long string which should be trimmed. Lorem ipsum dolor sit amet. + Hello %s! + + Third: %3$s Repeated: %3$s First: %1$s Second: %2$d + Escaped: \%s First: %1$s Invalid: %20$s + + Reference One + Reference Few + Reference Other + + Yes + 1111 + 2222 + 3333 + + 8 + 2 + + + @string/plural_one + @string/plural_few + @string/plural_other + + + Quantity One + Quantity Few + + + 16dp + 16dp + 56dip + 4dip + 3dip + 1dip + 2dip + \ No newline at end of file