/*
* Copyright (C) 2013 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.
*/
package com.android.tools.idea.templates;
import com.android.SdkConstants;
import com.android.annotations.VisibleForTesting;
import com.android.manifmerger.*;
import com.android.sdklib.SdkVersionInfo;
import com.android.ide.common.xml.XmlFormatPreferences;
import com.android.ide.common.xml.XmlFormatStyle;
import com.android.ide.common.xml.XmlPrettyPrinter;
import com.android.resources.ResourceFolderType;
import com.android.sdklib.AndroidTargetHash;
import com.android.sdklib.AndroidVersion;
import com.android.sdklib.IAndroidTarget;
import com.android.tools.idea.gradle.project.GradleProjectImporter;
import com.android.tools.idea.gradle.util.GradleUtil;
import com.android.utils.SdkUtils;
import com.android.utils.StdLogger;
import com.android.utils.XmlUtils;
import com.google.common.base.Charsets;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.*;
import com.intellij.util.SystemProperties;
import freemarker.cache.TemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.DefaultObjectWrapper;
import freemarker.template.TemplateException;
import org.jetbrains.android.sdk.AndroidSdkData;
import org.jetbrains.android.sdk.AndroidSdkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.w3c.dom.*;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import javax.xml.parsers.SAXParserFactory;
import java.io.*;
import java.net.URL;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.android.SdkConstants.*;
import static com.android.tools.idea.templates.Parameter.Constraint;
import static com.android.tools.idea.templates.TemplateManager.getTemplateRootFolder;
import static com.android.tools.idea.templates.TemplateMetadata.*;
import static com.android.tools.idea.templates.TemplateUtils.readTextFile;
/**
* Handler which manages instantiating FreeMarker templates, copying resources
* and merging into existing files
*/
public class Template {
private static final Logger LOG = Logger.getInstance("#org.jetbrains.android.templates.Template");
/** Highest supported format; templates with a higher number will be skipped
* <p>
* <ul>
* <li> 1: Initial format, supported by ADT 20 and up.
* <li> 2: ADT 21 and up. Boolean variables that have a default value and are not
* edited by the user would end up as strings in ADT 20; now they are always
* proper Booleans. Templates which rely on this should specify format >= 2.
* <li> 3: The wizard infrastructure passes the {@code isNewProject} boolean variable
* to indicate whether a wizard is created as part of a new blank project
* <li> 4: Constraint type app_package ({@link Constraint#APP_PACKAGE}), provides
* srcDir, resDir and manifestDir variables for locations of files
* </ul>
*/
static final int CURRENT_FORMAT = 4;
/**
* Directory within the template which contains the resources referenced
* from the template.xml file
*/
private static final String DATA_ROOT = "root";
/** Reserved filename which describes each template */
public static final String TEMPLATE_XML_NAME = "template.xml";
/** The settings.gradle lives at project root and points gradle at the build files for individual modules in their subdirectories */
public static final String GRADLE_PROJECT_SETTINGS_FILE = "settings.gradle";
/** Finds include ':module_name_1', ':module_name_2',... statements in settings.gradle files */
private static final Pattern INCLUDE_PATTERN = Pattern.compile("(^|\\n)\\s*include +(':[^']+', *)*':[^']+'");
/** Finds compile '<maven coordinates' in build.gradle files */
private static final Pattern COMPILE_PATTERN = Pattern.compile("compile[ \\t]*'([^'\\n]+)'");
private static final String INDENT = " ";
/**
* Most recent thrown exception during template instantiation. This should
* basically always be null. Used by unit tests to see if any template
* instantiation recorded a failure.
*/
@VisibleForTesting
public static Exception ourMostRecentException;
// Various tags and attributes used in the template metadata files - template.xml,
// globals.xml.ftl, recipe.xml.ftl, etc.
public static final String TAG_MERGE = "merge";
public static final String TAG_EXECUTE = "execute";
public static final String TAG_GLOBALS = "globals";
public static final String TAG_GLOBAL = "global";
public static final String TAG_PARAMETER = "parameter";
public static final String TAG_COPY = "copy";
public static final String TAG_INSTANTIATE = "instantiate";
public static final String TAG_OPEN = "open";
public static final String TAG_THUMB = "thumb";
public static final String TAG_THUMBS = "thumbs";
public static final String TAG_DEPENDENCY = "dependency";
public static final String TAG_ICONS = "icons";
public static final String TAG_MKDIR = "mkdir";
public static final String ATTR_FORMAT = "format";
public static final String ATTR_VALUE = "value";
public static final String ATTR_DEFAULT = "default";
public static final String ATTR_SUGGEST = "suggest";
public static final String ATTR_ID = "id";
public static final String ATTR_NAME = "name";
public static final String ATTR_DESCRIPTION = "description";
public static final String ATTR_VERSION = "version";
public static final String ATTR_MAVEN = "mavenUrl";
public static final String ATTR_TYPE = "type";
public static final String ATTR_HELP = "help";
public static final String ATTR_FILE = "file";
public static final String ATTR_TO = "to";
public static final String ATTR_FROM = "from";
public static final String ATTR_AT = "at";
public static final String ATTR_CONSTRAINTS = "constraints";
public static final String ATTR_VISIBILITY = "visibility";
public static final String ATTR_SOURCE_URL = "href";
public static final String ATTR_TEMPLATE_MERGE_STRATEGY = "templateMergeStrategy";
public static final String VALUE_MERGE_STRATEGY_REPLACE = "replace";
public static final String VALUE_MERGE_STRATEGY_PRESERVE = "preserve";
public static final String CATEGORY_ACTIVITIES = "activities";
public static final String CATEGORY_ACTIVITY = "Activity";
public static final String CATEGORY_PROJECTS = "gradle-projects";
public static final String CATEGORY_OTHER = "other";
public static final String CATEGORY_APPLICATION = "Application";
public static final String BLOCK_DEPENDENCIES = "dependencies";
/**
* List of files to open after the wizard has been created (these are
* identified by {@link #TAG_OPEN} elements in the recipe file
*/
private final List<File> myFilesToOpen = Lists.newArrayList();
/** Path to the directory containing the templates */
private final File myTemplateRoot;
/* The base directory the template is expanded into */
private File myOutputRoot;
/* The directory of the module root for the project being worked with */
private File myModuleRoot;
/** The template loader which is responsible for finding (and sharing) template files */
private final MyTemplateLoader myLoader;
private TemplateMetadata myMetadata;
private Project myProject;
private boolean myNeedsGradleSync;
/** Creates a new {@link Template} for the given root path */
@NotNull
public static Template createFromPath(@NotNull File rootPath) {
return new Template(rootPath);
}
/** Creates a new {@link Template} for the template name, which should
* be relative to the templates directory */
@NotNull
public static Template createFromName(@NotNull String category, @NotNull String name) {
TemplateManager manager = TemplateManager.getInstance();
// Use the TemplateManager iteration which should merge contents between the
// extras/templates/ and tools/templates folders and pick the most recent version
List<File> templates = manager.getTemplates(category);
for (File file : templates) {
if (file.getName().equals(name) && category.equals(file.getParentFile().getName())) {
return new Template(file);
}
}
return new Template(new File(getTemplateRootFolder(), category + File.separator + name));
}
private Template(@NotNull File rootPath) {
myTemplateRoot = rootPath;
myLoader = new MyTemplateLoader(myTemplateRoot.getPath());
}
/**
* Executes the template, rendering it to output files under the given module root directory.
*
* @param outputRootPath the filesystem directory that represents the root directory where the template will be expanded.
* @param moduleRootPath the filesystem directory that represents the root of the IDE project module for the template being expanded.
* @param args the key/value pairs that are fed into the input parameters for the template.
*/
public void render(@NotNull File outputRootPath, @NotNull File moduleRootPath, @NotNull Map<String, Object> args) {
render(outputRootPath, moduleRootPath, args, null);
}
/**
* Executes the template, rendering it to output files under the given module root directory.
*
* @param outputRootPath the filesystem directory that represents the root directory where the template will be expanded.
* @param moduleRootPath the filesystem directory that represents the root of the IDE project module for the template being expanded.
* @param args the key/value pairs that are fed into the input parameters for the template.
* @param project the target project of this template.
*/
public void render(@NotNull File outputRootPath, @NotNull File moduleRootPath, @NotNull Map<String, Object> args,
@Nullable Project project) {
assert outputRootPath.isDirectory() : outputRootPath;
myFilesToOpen.clear();
myOutputRoot = outputRootPath;
myModuleRoot = moduleRootPath;
myProject = project;
Map<String, Object> paramMap = createParameterMap(args);
enforceParameterTypes(getMetadata(), args);
Configuration freemarker = new Configuration();
freemarker.setObjectWrapper(new DefaultObjectWrapper());
freemarker.setTemplateLoader(myLoader);
processFile(freemarker, new File(TEMPLATE_XML_NAME), paramMap);
// Handle dependencies
if (paramMap.containsKey(TemplateMetadata.ATTR_DEPENDENCIES_LIST)) {
Object maybeDependencyList = paramMap.get(TemplateMetadata.ATTR_DEPENDENCIES_LIST);
if (maybeDependencyList instanceof List) {
List<String> dependencyList = (List<String>)maybeDependencyList;
if (!dependencyList.isEmpty()) {
try {
mergeDependenciesIntoFile(freemarker, paramMap, GradleUtil.getGradleBuildFilePath(moduleRootPath));
myNeedsGradleSync = true;
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
if (myNeedsGradleSync && myProject != null) {
GradleProjectImporter.getInstance().requestProjectSync(myProject, null);
}
}
@NotNull
public File getRootPath() {
return myTemplateRoot;
}
@Nullable
public TemplateMetadata getMetadata() {
if (myMetadata == null) {
myMetadata = TemplateManager.getInstance().getTemplate(myTemplateRoot);
}
return myMetadata;
}
@NotNull
public List<File> getFilesToOpen() {
return myFilesToOpen;
}
@NotNull
public static Map<String, Object> createParameterMap(@NotNull Map<String, Object> args) {
// Create the data model.
final Map<String, Object> paramMap = new HashMap<String, Object>();
// Builtin conversion methods
paramMap.put("slashedPackageName", new FmSlashedPackageNameMethod());
paramMap.put("camelCaseToUnderscore", new FmCamelCaseToUnderscoreMethod());
paramMap.put("underscoreToCamelCase", new FmUnderscoreToCamelCaseMethod());
paramMap.put("activityToLayout", new FmActivityToLayoutMethod());
paramMap.put("layoutToActivity", new FmLayoutToActivityMethod());
paramMap.put("classToResource", new FmClassNameToResourceMethod());
paramMap.put("escapeXmlAttribute", new FmEscapeXmlAttributeMethod());
paramMap.put("escapeXmlText", new FmEscapeXmlStringMethod());
paramMap.put("escapeXmlString", new FmEscapeXmlStringMethod());
paramMap.put("escapePropertyValue", new FmEscapePropertyValueMethod());
paramMap.put("extractLetters", new FmExtractLettersMethod());
// Dependency list
paramMap.put(TemplateMetadata.ATTR_DEPENDENCIES_LIST, new LinkedList<String>());
// Root folder of the templates
if (ApplicationManager.getApplication() != null && getTemplateRootFolder() != null) {
paramMap.put("templateRoot", getTemplateRootFolder().getAbsolutePath());
}
// Wizard parameters supplied by user, specific to this template
paramMap.putAll(args);
return paramMap;
}
/**
* Iterate through parameters and ensure the given map has the correct for each
* parameter.
*/
private static void enforceParameterTypes(@NotNull TemplateMetadata metadata, @NotNull Map<String, Object> args) {
for (Parameter p : metadata.getParameters()) {
Object o = args.get(p.id);
if (o == null) {
continue;
}
switch (p.type) {
case STRING:
if (!(o instanceof String)) {
args.put(p.id, o.toString());
}
break;
case BOOLEAN:
if (!(o instanceof Boolean)) {
args.put(p.id, Boolean.parseBoolean(o.toString()));
}
break;
case ENUM:
break;
case SEPARATOR:
break;
case EXTERNAL:
break;
case CUSTOM:
break;
}
}
convertApisToInt(args);
}
public static void convertApisToInt(@NotNull Map<String, Object> args) {
convertToInt(ATTR_BUILD_API, args);
convertToInt(ATTR_MIN_API_LEVEL, args);
convertToInt(TemplateMetadata.ATTR_TARGET_API, args);
}
private static void convertToInt(@NotNull String key, @NotNull Map<String, Object> args) {
Object value = args.get(key);
if (value instanceof String) {
Integer result;
try {
result = Integer.parseInt((String)value);
} catch (NumberFormatException e) {
result = SdkVersionInfo.getApiByPreviewName((String)value, true /* Recognize Unknowns */);
}
args.put(key, result);
}
}
/** Read the given FreeMarker file and process the variable definitions */
private void processFile(@NotNull final Configuration freemarker, @NotNull File file, @NotNull final Map<String, Object> paramMap) {
try {
String xml;
if (hasExtension(file, DOT_XML)) {
// Just read the file
xml = readTextFile(getTemplateFile(file));
if (xml == null) {
return;
}
} else {
myLoader.setTemplateFile(getTemplateFile(file));
xml = processFreemarkerTemplate(freemarker, paramMap, file.getName());
}
xml = XmlUtils.stripBom(xml);
InputSource inputSource = new InputSource(new StringReader(xml));
SAXParserFactory.newInstance().newSAXParser().parse(inputSource, new DefaultHandler() {
@Override
public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException {
if (TAG_PARAMETER.equals(name)) {
String id = attributes.getValue(ATTR_ID);
if (!paramMap.containsKey(id)) {
String value = attributes.getValue(ATTR_DEFAULT);
Object mapValue = value;
if (value != null && !value.isEmpty()) {
String type = attributes.getValue(ATTR_TYPE);
if ("boolean".equals(type)) {
mapValue = Boolean.valueOf(value);
}
}
paramMap.put(id, mapValue);
}
} else if (TAG_GLOBAL.equals(name)) {
String id = attributes.getValue(ATTR_ID);
if (!paramMap.containsKey(id)) {
paramMap.put(id, TypedVariable.parseGlobal(attributes));
}
} else if (TAG_GLOBALS.equals(name)) {
// Handle evaluation of variables
File globalsFile = getPath(attributes, ATTR_FILE);
if (globalsFile != null) {
processFile(freemarker, globalsFile, paramMap);
} // else: <globals> root element
} else if (TAG_EXECUTE.equals(name)) {
File recipeFile = getPath(attributes, ATTR_FILE);
if (recipeFile != null) {
executeRecipeFile(freemarker, recipeFile, paramMap);
}
} else if (!name.equals("template") && !name.equals("category") && !name.equals("option") && !name.equals(TAG_THUMBS) &&
!name.equals(TAG_THUMB) && !name.equals(TAG_ICONS) && !name.equals(TAG_DEPENDENCY) && !name.equals(TAG_FORMFACTOR)) {
LOG.error("WARNING: Unknown template directive " + name);
}
}
});
} catch (Exception e) {
//noinspection AssignmentToStaticFieldFromInstanceMethod
ourMostRecentException = e;
LOG.warn(e);
}
}
/** Executes the given recipe file: copying, merging, instantiating, opening files etc */
private void executeRecipeFile(@NotNull final Configuration freemarker, @NotNull File file, @NotNull final Map<String,
Object> paramMap) {
try {
myLoader.setTemplateFile(getTemplateFile(file));
String xml = processFreemarkerTemplate(freemarker, paramMap, file.getName());
xml = XmlUtils.stripBom(xml);
InputSource inputSource = new InputSource(new StringReader(xml));
SAXParserFactory.newInstance().newSAXParser().parse(inputSource, new DefaultHandler() {
@Override
public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException {
try {
boolean instantiate = TAG_INSTANTIATE.equals(name);
if (TAG_COPY.equals(name) || instantiate) {
File fromFile = getPath(attributes, ATTR_FROM);
File toFile = getPath(attributes, ATTR_TO);
if (toFile == null || toFile.getPath().isEmpty()) {
toFile = getPath(attributes, ATTR_FROM);
toFile = TemplateUtils.stripSuffix(toFile, DOT_FTL);
}
if (instantiate) {
instantiate(freemarker, paramMap, fromFile, toFile);
}
else {
copyTemplateResource(fromFile, toFile);
}
}
else if (TAG_MERGE.equals(name)) {
File fromFile = getPath(attributes, ATTR_FROM);
File toFile = getPath(attributes, ATTR_TO);
if (toFile == null || toFile.getPath().isEmpty()) {
toFile = getPath(attributes, ATTR_FROM);
toFile = TemplateUtils.stripSuffix(toFile, DOT_FTL);
}
// Resources in template.xml are located within root/
merge(freemarker, paramMap, fromFile, toFile);
}
else if (name.equals(TAG_OPEN)) {
// The relative path here is within the output directory:
File relativePath = getPath(attributes, ATTR_FILE);
if (relativePath != null && !relativePath.getPath().isEmpty()) {
myFilesToOpen.add(relativePath);
}
}
else if (name.equals(TAG_MKDIR)) {
// The relative path here is within the output directory:
File relativePath = getPath(attributes, ATTR_AT);
if (relativePath != null && !relativePath.getPath().isEmpty()) {
File targetFile = getTargetFile(relativePath);
checkedCreateDirectoryIfMissing(targetFile);
}
} else if (name.equals(TAG_DEPENDENCY)) {
String url = attributes.getValue(ATTR_MAVEN);
//noinspection unchecked
List<String> dependencyList = (List<String>)paramMap.get(TemplateMetadata.ATTR_DEPENDENCIES_LIST);
if (url != null) {
dependencyList.add(url);
}
}
else if (!name.equals("recipe")) {
LOG.warn("WARNING: Unknown template directive " + name);
}
}
catch (Exception e) {
//noinspection AssignmentToStaticFieldFromInstanceMethod
ourMostRecentException = e;
LOG.warn(e);
}
}
});
} catch (Exception e) {
//noinspection AssignmentToStaticFieldFromInstanceMethod
ourMostRecentException = e;
LOG.warn(e);
}
}
private void merge(@NotNull final Configuration freemarker,
@NotNull final Map<String, Object> paramMap,
@NotNull File relativeFrom,
@NotNull File to) throws IOException, TemplateException {
String targetText = null;
to = getTargetFile(to);
if (!(hasExtension(to, DOT_XML) || hasExtension(to, DOT_GRADLE))) {
throw new RuntimeException("Only XML or Gradle files can be merged at this point: " + to);
}
if (to.exists()) {
targetText = Files.toString(to, Charsets.UTF_8);
} else if (to.getParentFile() != null) {
//noinspection ResultOfMethodCallIgnored
checkedCreateDirectoryIfMissing(to.getParentFile());
}
if (targetText == null) {
// The target file doesn't exist: don't merge, just copy
boolean instantiate = hasExtension(relativeFrom, DOT_FTL);
if (instantiate) {
instantiate(freemarker, paramMap, relativeFrom, to);
} else {
copyTemplateResource(relativeFrom, to);
}
return;
}
String sourceText;
File from = getFullPath(relativeFrom);
if (hasExtension(relativeFrom, DOT_FTL)) {
// Perform template substitution of the template prior to merging
myLoader.setTemplateFile(from);
sourceText = processFreemarkerTemplate(freemarker, paramMap, from.getName());
} else {
sourceText = readTextFile(from);
if (sourceText == null) {
return;
}
}
String contents;
if (to.getName().equals(GRADLE_PROJECT_SETTINGS_FILE)) {
contents = mergeGradleSettingsFile(sourceText, targetText);
myNeedsGradleSync = true;
} else if (to.getName().equals(SdkConstants.FN_BUILD_GRADLE)) {
contents = GradleFileMerger.mergeGradleFiles(sourceText, targetText, myProject);
myNeedsGradleSync = true;
} else if (hasExtension(to, DOT_XML)) {
contents = mergeXml(sourceText, targetText, to, paramMap);
} else {
throw new RuntimeException("Only XML or Gradle settings files can be merged at this point: " + to);
}
writeFile(contents, to);
}
private static String mergeXml(String sourceXml, String targetXml, File targetFile, Map<String, Object> paramMap) {
Document currentDocument = XmlUtils.parseDocumentSilently(targetXml, true);
assert currentDocument != null : targetXml;
Document fragment = XmlUtils.parseDocumentSilently(sourceXml, true);
assert fragment != null : sourceXml;
XmlFormatStyle formatStyle = XmlFormatStyle.MANIFEST;
boolean modified;
boolean ok;
String fileName = targetFile.getName();
if (fileName.equals(SdkConstants.FN_ANDROID_MANIFEST_XML)) {
XmlDocument mergedDocument = mergeManifest(targetFile, sourceXml);
modified = ok = mergedDocument != null;
if (ok) {
currentDocument = mergedDocument.getXml();
}
} else {
// Merge plain XML files
String parentFolderName = targetFile.getParentFile().getName();
ResourceFolderType folderType = ResourceFolderType.getFolderType(parentFolderName);
if (folderType != null) {
formatStyle = getXmlFormatStyleForFile(targetFile);
} else {
formatStyle = XmlFormatStyle.FILE;
}
modified = mergeResourceFile(currentDocument, fragment, folderType, paramMap);
ok = true;
}
// Finally write out the merged file (formatting etc)
String contents = null;
if (ok) {
if (modified) {
contents = XmlPrettyPrinter.prettyPrint(currentDocument, createXmlFormatPreferences(), formatStyle, "\n", targetXml.endsWith("\n"));
}
} else {
// Just insert into file along with comment, using the "standard" conflict
// syntax that many tools and editors recognize.
contents = wrapWithMergeConflict(targetXml, sourceXml);
}
return contents;
}
/**
* Wraps the given strings in the standard conflict syntax
* @param original
* @param added
* @return
*/
private static String wrapWithMergeConflict(String original, String added) {
String sep = "\n";
return "<<<<<<< Original" + sep
+ original + sep
+ "=======" + sep
+ added
+ ">>>>>>> Added" + sep;
}
/** Merges the given resource file contents into the given resource file
* @param paramMap */
private static boolean mergeResourceFile(@NotNull Document currentDocument, @NotNull Document fragment,
@Nullable ResourceFolderType folderType, @NotNull Map<String, Object> paramMap) {
boolean modified = false;
// Copy namespace declarations
NamedNodeMap attributes = fragment.getDocumentElement().getAttributes();
if (attributes != null) {
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attribute = (Attr)attributes.item(i);
if (attribute.getName().startsWith(XMLNS_PREFIX)) {
currentDocument.getDocumentElement().setAttribute(attribute.getName(), attribute.getValue());
}
}
}
// For layouts for example, I want to *append* inside the root all the
// contents of the new file.
// But for resources for example, I want to combine elements which specify
// the same name or id attribute.
// For elements like manifest files we need to insert stuff at the right
// location in a nested way (activities in the application element etc)
// but that doesn't happen for the other file types.
Element root = fragment.getDocumentElement();
NodeList children = root.getChildNodes();
List<Node> nodes = new ArrayList<Node>(children.getLength());
for (int i = children.getLength() - 1; i >= 0; i--) {
Node child = children.item(i);
nodes.add(child);
root.removeChild(child);
}
Collections.reverse(nodes);
root = currentDocument.getDocumentElement();
if (folderType == ResourceFolderType.VALUES) {
// Try to merge items of the same name
Map<String, Node> old = new HashMap<String, Node>();
NodeList newSiblings = root.getChildNodes();
for (int i = newSiblings.getLength() - 1; i >= 0; i--) {
Node child = newSiblings.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) child;
String name = getResourceId(element);
if (name != null) {
old.put(name, element);
}
}
}
for (Node node : nodes) {
if (node.getNodeType() == Node.ELEMENT_NODE) {
// Chances are, we will put the node from the clean template into the original document, so import it.
Element element = (Element) currentDocument.importNode(node, true);
String mergeStrategy = element.getAttribute(ATTR_TEMPLATE_MERGE_STRATEGY);
// Remove the "templateMergeStrategy" attribute from the final output.
element.removeAttribute(ATTR_TEMPLATE_MERGE_STRATEGY);
String name = getResourceId(element);
Node replace = name != null ? old.get(name) : null;
if (replace != null) {
// 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!
if (VALUE_MERGE_STRATEGY_REPLACE.equals(mergeStrategy)) {
root.replaceChild(element, replace);
modified = true;
} else if (VALUE_MERGE_STRATEGY_PRESERVE.equals(mergeStrategy)) {
// Preserve the existing value.
} else {
// No explicit directive given, preserve the original value by default.
LOG.warn("Warning: Ignoring name conflict in resource file for name " + name);
}
} else {
root.appendChild(element);
modified = true;
}
}
}
} else {
// In other file types, such as layouts, just append all the new content
// at the end.
for (Node node : nodes) {
root.appendChild(currentDocument.importNode(node, true));
modified = true;
}
}
return modified;
}
/** Merges the given manifest fragment into the given manifest file */
@Nullable
private static XmlDocument mergeManifest(@NotNull File targetManifest, @NotNull String mergeText) {
File tempFile = null;
try {
tempFile = FileUtil.createTempFile("manifmerge", ".xml");
FileUtil.writeToFile(tempFile, mergeText);
MergingReport mergeReport = ManifestMerger2.newMerger(targetManifest,new StdLogger(StdLogger.Level.INFO),
ManifestMerger2.MergeType.APPLICATION).
addLibraryManifest(tempFile).merge();
if (mergeReport.getMergedDocument().isPresent()) {
return mergeReport.getMergedDocument().get();
}
return null;
}
catch (IOException e) {
LOG.error(e);
}
catch (ManifestMerger2.MergeFailureException e) {
LOG.error(e);
try {
FileUtil.appendToFile(tempFile, String.format("<!--%s-->", e.getMessage()));
}
catch (IOException e1) {
LOG.error(e1);
}
} finally {
if (tempFile != null) {
tempFile.delete();
}
}
return null;
}
private static String mergeGradleSettingsFile(@NotNull String source, @NotNull String dest) throws IOException, TemplateException {
// TODO: Right now this is implemented as a dumb text merge. It would be much better to read it into PSI using IJ's Groovy support.
// If Gradle build files get first-class PSI support in the future, we will pick that up cheaply. At the moment, Our Gradle-Groovy
// support requires a project, which we don't necessarily have when instantiating a template.
StringBuilder contents = new StringBuilder(dest);
for (String line : Splitter.on('\n').omitEmptyStrings().trimResults().split(source)) {
if (!line.startsWith("include")) {
throw new RuntimeException("When merging settings.gradle files, only include directives can be merged.");
}
line = line.substring("include".length()).trim();
Matcher matcher = INCLUDE_PATTERN.matcher(contents);
if (matcher.find()) {
contents.insert(matcher.end(), ", " + line);
} else {
contents.insert(0, "include " + line + SystemProperties.getLineSeparator());
}
}
return contents.toString();
}
/**
* Merge the given dependency URLs into the given build.gradle file
* @param paramMap the parameters to merge
* @param gradleBuildFile the build.gradle file which will be written with the merged dependencies
*/
private void mergeDependenciesIntoFile(@NotNull final Configuration freemarker, @NotNull Map<String, Object> paramMap,
@NotNull File gradleBuildFile) throws IOException, TemplateException {
File templateFile = new File(TemplateManager.getTemplateRootFolder().getPath(),
FileUtil.join("gradle", "utils", "dependencies.gradle.ftl"));
myLoader.setTemplateFile(templateFile);
String contents = processFreemarkerTemplate(freemarker, paramMap, templateFile.getName());
String destinationContents;
if (gradleBuildFile.exists()) {
destinationContents = TemplateUtils.readTextFile(gradleBuildFile);
} else {
destinationContents = "";
}
if (destinationContents == null) {
destinationContents = "";
}
String result = GradleFileMerger.mergeGradleFiles(contents, destinationContents, myProject);
writeFile(result, gradleBuildFile);
}
/** Instantiates the given template file into the given output file */
private void instantiate(
@NotNull final Configuration freemarker,
@NotNull final Map<String, Object> paramMap,
@NotNull File relativeFrom,
@NotNull File to) throws IOException, TemplateException {
// For now, treat extension-less files as directories... this isn't quite right
// so I should refine this! Maybe with a unique attribute in the template file?
boolean isDirectory = relativeFrom.getName().indexOf('.') == -1;
if (isDirectory) {
// It's a directory
copyTemplateResource(relativeFrom, to);
} else {
File from = getFullPath(relativeFrom);
myLoader.setTemplateFile(from);
String contents = processFreemarkerTemplate(freemarker, paramMap, from.getName());
contents = format(contents, to);
File targetFile = getTargetFile(to);
VfsUtil.createDirectories(targetFile.getParentFile().getAbsolutePath());
writeFile(contents, targetFile);
}
}
@NotNull
private File getFullPath(@NotNull File fromFile) {
if (fromFile.isAbsolute()) {
return fromFile;
} else {
// If it's a relative file path, get the data from the template data directory
return new File(myTemplateRoot, DATA_ROOT + File.separator + fromFile);
}
}
@NotNull
private File getTargetFile(@NotNull File file) throws IOException {
if (file.isAbsolute()) {
return file;
}
return new File(myOutputRoot, file.getPath());
}
@NotNull
private File getTemplateFile(@NotNull File relativeFile) throws IOException {
return new File(myTemplateRoot, relativeFile.getPath());
}
@NotNull
private static String processFreemarkerTemplate(@NotNull Configuration freemarker,
@NotNull Map<String, Object> paramMap, @NotNull String name)
throws IOException, TemplateException {
freemarker.template.Template inputsTemplate = freemarker.getTemplate(name);
StringWriter out = new StringWriter();
inputsTemplate.process(paramMap, out);
out.flush();
return out.toString();
}
@NotNull
private static XmlFormatPreferences createXmlFormatPreferences() {
// TODO: implement
return XmlFormatPreferences.defaults();
}
/**
* Returns the {@link XmlFormatStyle} to use for resource files of the given path.
*
* @param file the file to find the style for
* @return the suitable format style to use
*/
@NotNull
private static XmlFormatStyle getXmlFormatStyleForFile(@NotNull File file) {
if (SdkConstants.FN_ANDROID_MANIFEST_XML.equals(file.getName())) {
return XmlFormatStyle.MANIFEST;
}
if (file.getParent() != null) {
String parentName = file.getParentFile().getName();
ResourceFolderType folderType = ResourceFolderType.getFolderType(parentName);
return getXmlFormatStyleForFolderType(folderType);
}
return XmlFormatStyle.FILE;
}
/**
* Returns the {@link XmlFormatStyle} to use for resource files in the given resource
* folder
*
* @param folderType the type of folder containing the resource file
* @return the suitable format style to use
*/
@NotNull
private static XmlFormatStyle getXmlFormatStyleForFolderType(@NotNull ResourceFolderType folderType) {
switch (folderType) {
case LAYOUT:
return XmlFormatStyle.LAYOUT;
case COLOR:
case VALUES:
return XmlFormatStyle.RESOURCE;
case ANIM:
case ANIMATOR:
case DRAWABLE:
case INTERPOLATOR:
case MENU:
default:
return XmlFormatStyle.FILE;
}
}
private static String getResourceId(@NotNull Element element) {
String name = element.getAttribute(ATTR_NAME);
if (name == null) {
name = element.getAttribute(ATTR_ID);
}
return name;
}
private static String format(@NotNull String contents, File to) {
// TODO: Implement this
return contents;
}
/** Copy a template resource */
private void copyTemplateResource(
@NotNull File relativeFrom,
@NotNull File output) throws IOException {
copy(getFullPath(relativeFrom), getTargetFile(output));
}
/**
* Copies the given source file into the given destination file (where the
* source is allowed to be a directory, in which case the whole directory is
* copied recursively)
*/
private void copy(@NotNull File src, @NotNull File dest) throws IOException {
VirtualFile sourceFile = VfsUtil.findFileByIoFile(src, true);
assert sourceFile != null : src;
File parentPath = (src.isDirectory() ? dest : dest.getParentFile());
VirtualFile destFolder = checkedCreateDirectoryIfMissing(parentPath);
if (src.isDirectory()) {
copyDirectory(sourceFile, destFolder);
}
else {
com.intellij.openapi.editor.Document document = FileDocumentManager.getInstance().getDocument(sourceFile);
if (document != null) {
writeFile(document.getText(), dest);
}
else {
VfsUtilCore.copyFile(this, sourceFile, destFolder, dest.getName());
}
}
}
/**
* VfsUtil#copyDirectory messes up the undo stack, most likely by trying to
* create directory even if it already exists. This is an undo-friendly
* replacement.
*/
private void copyDirectory(@NotNull final VirtualFile src, @NotNull final VirtualFile dest) throws IOException {
final File destinationFile = VfsUtilCore.virtualToIoFile(dest);
VfsUtilCore.visitChildrenRecursively(src, new VirtualFileVisitor() {
@Override
public boolean visitFile(@NotNull VirtualFile file) {
try {
return copyFile(file, src, destinationFile, dest);
}
catch (IOException e) {
throw new VisitorException(e);
}
}
}, IOException.class);
}
private boolean copyFile(VirtualFile file, VirtualFile src, File destinationFile, VirtualFile dest) throws IOException {
String relativePath = VfsUtilCore.getRelativePath(file, src, File.separatorChar);
if (relativePath == null) {
LOG.error(file.getPath() + " is not a child of " + src, new Exception());
return false;
}
if (file.isDirectory()) {
checkedCreateDirectoryIfMissing(new File(destinationFile, relativePath));
}
else {
VirtualFile targetDir = dest;
if (relativePath.indexOf(File.separatorChar) > 0) {
String directories = relativePath.substring(0, relativePath.lastIndexOf(File.separatorChar));
File newParent = new File(destinationFile, directories);
targetDir = checkedCreateDirectoryIfMissing(newParent);
}
VfsUtilCore.copyFile(this, file, targetDir);
}
return true;
}
/**
* Creates a directory for the given file and returns the VirtualFile object.
*
* @return virtual file object for the given path. It can never be null.
*/
@NotNull
public static VirtualFile checkedCreateDirectoryIfMissing(@NotNull File directory) throws IOException {
VirtualFile dir = VfsUtil.createDirectoryIfMissing(directory.getAbsolutePath());
if (dir == null) {
throw new IOException("Unable to create " + directory.getAbsolutePath());
}
else {
return dir;
}
}
/**
* Replaces the contents of the given file with the given string. Outputs
* text in UTF-8 character encoding. The file is created if it does not
* already exist.
*/
private void writeFile(@Nullable String contents, @NotNull File to) throws IOException {
if (contents == null) {
return;
}
VirtualFile vf = LocalFileSystem.getInstance().findFileByIoFile(to);
if (vf == null) {
// Creating a new file
VirtualFile parentDir = checkedCreateDirectoryIfMissing(to.getParentFile());
vf = parentDir.createChildData(this, to.getName());
}
com.intellij.openapi.editor.Document document = FileDocumentManager.getInstance().getDocument(vf);
if (document != null) {
document.setText(contents.replaceAll("\r\n", "\n"));
FileDocumentManager.getInstance().saveDocument(document);
}
else {
vf.setBinaryContent(contents.getBytes(Charsets.UTF_8), -1, -1, this);
}
}
/**
* Retrieve the named parameter from the attribute list and unescape it from XML as a path
* @param attributes the map of attributes
* @param name the name of the attribute to retrieve
*/
@Nullable
private static File getPath(@NotNull Attributes attributes, @NotNull String name) {
String value = attributes.getValue(name);
if (value == null) {
return null;
}
String unescapedString = XmlUtils.fromXmlAttributeValue(value);
return new File(FileUtil.toSystemDependentName(unescapedString));
}
/**
* A custom {@link TemplateLoader} which locates and provides templates
* within the plugin .jar file
*/
private static final class MyTemplateLoader implements TemplateLoader {
private String myPrefix;
public MyTemplateLoader(@Nullable String prefix) {
myPrefix = prefix;
}
public void setTemplateFile(@NotNull File file) {
setTemplateParent(file.getParentFile());
}
public void setTemplateParent(@NotNull File parent) {
myPrefix = parent.getPath();
}
@Override
@NotNull
public Reader getReader(@NotNull Object templateSource, @NotNull String encoding) throws IOException {
URL url = (URL) templateSource;
return new InputStreamReader(url.openStream(), encoding);
}
@Override
public long getLastModified(Object templateSource) {
return 0;
}
@Override
@Nullable
public Object findTemplateSource(@NotNull String name) throws IOException {
String path = myPrefix != null ? myPrefix + '/' + name : name;
File file = new File(path);
if (file.exists()) {
return SdkUtils.fileToUrl(file);
}
return null;
}
@Override
public void closeTemplateSource(Object templateSource) throws IOException {
}
}
/**
* A {@link ManifestMerger} {@link ICallback} that returns the
* proper API level for known API codenames.
*/
static class AdtManifestMergeCallback implements ICallback {
@Override
public int queryCodenameApiLevel(@NotNull String codename) {
try {
AndroidVersion version = new AndroidVersion(codename);
String hashString = AndroidTargetHash.getPlatformHashString(version);
AndroidSdkData sdkData = AndroidSdkUtils.tryToChooseAndroidSdk();
if (sdkData != null) {
IAndroidTarget t = sdkData.getLocalSdk().getTargetFromHashString(hashString);
if (t != null) {
return t.getVersion().getApiLevel();
}
}
}
catch (AndroidVersion.AndroidVersionException ignore) {
}
return ICallback.UNKNOWN_CODENAME;
}
}
/**
* Returns true iff the given file has the given extension (with or without .)
*/
private static boolean hasExtension(File file, String extension) {
String noDotExtension = extension.startsWith(".") ? extension.substring(1) : extension;
return Files.getFileExtension(file.getName()).equalsIgnoreCase(noDotExtension);
}
}