blob: b19f44f82f7f47cf07d04f1d5b45dd8325be6933 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.
*/
@file:JvmName("RecipeMergeUtils")
package com.android.tools.idea.templates
import com.android.SdkConstants.ATTR_ID
import com.android.SdkConstants.ATTR_NAME
import com.android.SdkConstants.FN_ANDROID_MANIFEST_XML
import com.android.SdkConstants.XMLNS_PREFIX
import com.android.manifmerger.ManifestMerger2
import com.android.manifmerger.MergingReport
import com.android.manifmerger.XmlDocument
import com.android.resources.ResourceFolderType
import com.android.tools.idea.templates.recipe.RenderingContext
import com.android.utils.StdLogger
import com.android.utils.XmlUtils
import com.google.common.base.Charsets
import com.google.common.collect.Lists.newArrayList
import com.intellij.lang.xml.XMLLanguage
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.text.StringUtil
import com.intellij.psi.PsiFileFactory
import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.xml.XmlComment
import com.intellij.psi.xml.XmlFile
import com.intellij.psi.xml.XmlTag
import com.intellij.psi.xml.XmlTagChild
import com.intellij.psi.xml.XmlText
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
/**
* Utility methods to support the recipe.xml merge instruction.
*/
private val LOG: Logger get() = logger(::LOG)
private const val MERGE_ATTR_STRATEGY = "templateMergeStrategy"
private const val MERGE_ATTR_STRATEGY_REPLACE = "replace"
private const val MERGE_ATTR_STRATEGY_PRESERVE = "preserve"
/**
* Merges sourceXml into targetXml/targetFile (targetXml is the contents of targetFile).
* @return the resulting xml if it still needs to be written to targetFile or null if the file has already been/doesn't need to be updated.
*/
fun mergeXml(context: RenderingContext, sourceXml: String, targetXml: String, targetFile: File): String {
val fileName = targetFile.name
var errors: String? = null
fun mergeManifest(): String? {
XmlUtils.parseDocumentSilently(targetXml, true) ?: error("$targetXml failed to parse")
XmlUtils.parseDocumentSilently(sourceXml, true) ?: error("$sourceXml failed to parse")
val report = mergeManifest(context.moduleRoot!!, targetFile, targetXml, sourceXml) ?: return null
if (report.result.isSuccess) {
return report.getMergedDocument(MergingReport.MergedManifestKind.MERGED)
}
// report.reportString isn't useful, it just says to look at the logs
// Also, some of the warnings are misleading -- e.g. "missing package declaration";
// that's deliberate. Users only have to deal with errors to get the manifest merge to succeed.
errors = report.loggingRecords.asSequence()
.filter { it.severity == MergingReport.Record.Severity.ERROR }
.joinToString("") { "* ${it.message}\n\n" }
.replace("AndroidManifest.xml", "current AndroidManifest.xml") // Error messages may refer to our internal temp name for the target manifest file
.replace("nevercreated.xml", "template AndroidManifest.xml")
.trim()
return null
}
fun mergePlainXml(): String {
val parentFolderName = targetFile.parentFile.name
val folderType = ResourceFolderType.getFolderType(parentFolderName)
// mergeResourceFile handles the file updates itself
return mergeResourceFile(context.project, context.warnings, targetXml, sourceXml, fileName, folderType)
}
return (if (fileName == FN_ANDROID_MANIFEST_XML) mergeManifest() else mergePlainXml())
?: // Just insert into file along with comment, using the "standard" conflict syntax that many tools and editors recognize.
wrapWithMergeConflict(targetXml, sourceXml).also {
context.warnings.add(
"Merge conflict for: ${targetFile.name}\nThis file must be fixed by hand. Errors encountered during the merge:\n\n$errors")
}
}
/**
* Merges the given resource file contents into the given resource file
*/
fun mergeResourceFile(project: Project,
warningsToAdd: MutableCollection<String>,
targetXml: String,
sourceXml: String,
fileName: String,
folderType: ResourceFolderType?): String {
val targetPsiFile = PsiFileFactory.getInstance(project)
.createFileFromText("targetFile", XMLLanguage.INSTANCE, StringUtil.convertLineSeparators(targetXml), false, true) as XmlFile
val sourcePsiFile = PsiFileFactory.getInstance(project)
.createFileFromText("sourceFile", XMLLanguage.INSTANCE, StringUtil.convertLineSeparators(sourceXml), false, true) as XmlFile
val root = targetPsiFile.document!!.rootTag ?: error("Cannot find XML root in target: $targetXml")
val attributes = sourcePsiFile.rootTag!!.attributes
attributes.filter {it.namespacePrefix == XMLNS_PREFIX}.forEach { root.setAttribute(it.name, it.value) }
if (folderType != ResourceFolderType.VALUES) {
// In other file types, such as layouts, just append all the new content at the end.
sourcePsiFile.rootTag!!.children.filterIsInstance<XmlTag>().forEach {
root.addSubTag(it, false)
}
return targetPsiFile.text
}
val prependElements = newArrayList<XmlTagChild>()
var indent: XmlText? = null
// Try to merge items of the same name
val old = root.subTags.associateBy { getResourceId(it) }
loop@ for (child in sourcePsiFile.rootTag!!.children) {
when (child) {
is XmlComment -> {
if (indent != null) {
prependElements.add(indent)
}
prependElements.add(child as XmlTagChild)
}
is XmlText -> indent = child
is XmlTag -> {
var subTag = child
val mergeStrategy = subTag.getAttributeValue(MERGE_ATTR_STRATEGY)
subTag.setAttribute(MERGE_ATTR_STRATEGY, null)
// remove the space left by the deleted attribute
CodeStyleManager.getInstance(project).reformat(subTag)
val name = getResourceId(subTag)
val replace = if (name == null) null else old[name]
if (replace == null) {
if (indent != null) {
prependElements.add(indent)
}
subTag = root.addSubTag(subTag, false)
prependElements.forEach {
root.addBefore(it, subTag)
}
prependElements.clear()
continue@loop
}
// There is an existing item with the same id. Either replace it or preserve it depending on the "templateMergeStrategy" attribute.
// If that attribute does not exist, default to preserving it.
// Let's say you've used the activity wizard once, and it emits some configuration parameter as a resource that
// it depends on, say "padding". Then the user goes and tweaks the padding to some other number.
// Now running the wizard a *second* time for some new activity, we should NOT go and set the value back to the template's default!
when {
MERGE_ATTR_STRATEGY_REPLACE == mergeStrategy -> {
val newChild = replace.replace(child)
// When we're replacing, the line is probably already indented. Skip the initial indent
if (newChild.prevSibling is XmlText && prependElements[0] is XmlText) {
prependElements.removeAt(0)
// If we're adding something we'll need a newline/indent after it
if (prependElements.isNotEmpty()) {
prependElements.add(indent)
}
}
for (element in prependElements) {
root.addBefore(element, newChild)
}
}
MERGE_ATTR_STRATEGY_PRESERVE == mergeStrategy -> {
// Preserve the existing value.
}
replace.text.trim() == child.text.trim() -> {
// There are no differences, do not issue a warning.
}
else -> // No explicit directive given, preserve the original value by default.
warningsToAdd.add("Ignoring conflict for the value: $name wanted: \"%${child.text}\" but it already is: \"%${replace.text}\" in the file: $fileName")
}
prependElements.clear()
}
}
}
return targetPsiFile.text
}
/**
* Merges the given manifest fragment into the given manifest file
*/
private fun mergeManifest(moduleRoot: File, targetManifest: File,
targetXml: String, mergeText: String): MergingReport? {
try {
val isMasterManifest = FileUtil.filesEqual(moduleRoot, targetManifest.parentFile)
val tempFile2 = File(targetManifest.parentFile, "nevercreated.xml")
val logger = StdLogger(StdLogger.Level.INFO)
return ManifestMerger2.newMerger(targetManifest, logger, ManifestMerger2.MergeType.APPLICATION)
.withFeatures(ManifestMerger2.Invoker.Feature.EXTRACT_FQCNS,
ManifestMerger2.Invoker.Feature.HANDLE_VALUE_CONFLICTS_AUTOMATICALLY,
ManifestMerger2.Invoker.Feature.NO_PLACEHOLDER_REPLACEMENT)
.addFlavorAndBuildTypeManifest(tempFile2)
.asType(if (isMasterManifest) XmlDocument.Type.MAIN else XmlDocument.Type.OVERLAY)
.withFileStreamProvider(object : ManifestMerger2.FileStreamProvider() {
@Throws(FileNotFoundException::class)
override fun getInputStream(file: File): InputStream {
val text = if (FileUtil.filesEqual(file, targetManifest)) targetXml else mergeText
return ByteArrayInputStream(text.toByteArray(Charsets.UTF_8))
}
})
.merge()
}
catch (e: ManifestMerger2.MergeFailureException) {
LOG.warn(e)
return null
}
}
private fun getResourceId(tag: XmlTag): String? = tag.getAttributeValue(ATTR_NAME) ?: tag.getAttributeValue(ATTR_ID)
/**
* Wraps the given strings in the standard conflict syntax
*/
private fun wrapWithMergeConflict(original: String, added: String): String =
"<<<<<<< Original\n$original\n=======\n$added>>>>>>> Added\n"