blob: 3858e5e91d01a29cd9dbc3898fc5ca4cb8334c44 [file] [log] [blame]
/*
* 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.annotations.VisibleForTesting;
import com.android.sdklib.SdkVersionInfo;
import com.android.tools.idea.stats.UsageTracker;
import com.android.tools.idea.templates.recipe.Recipe;
import com.android.tools.idea.templates.recipe.RecipeContext;
import com.android.utils.XmlUtils;
import com.google.common.collect.Lists;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ex.ProjectManagerEx;
import freemarker.template.Configuration;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
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.File;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import java.util.Map;
import static com.android.SdkConstants.DOT_XML;
import static com.android.tools.idea.templates.FreemarkerUtils.processFreemarkerTemplate;
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.hasExtension;
import static com.android.tools.idea.templates.TemplateUtils.readTextFile;
import static com.android.tools.idea.templates.parse.SaxUtils.getPath;
/**
* Handler which manages instantiating FreeMarker templates, copying resources
* and merging into existing files
*/
public class Template {
/**
* Reserved filename which describes each template
*/
public static final String TEMPLATE_XML_NAME = "template.xml";
// Various tags and attributes used in template.xml
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_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 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_TYPE = "type";
public static final String ATTR_HELP = "help";
public static final String ATTR_FILE = "file";
public static final String ATTR_CONSTRAINTS = "constraints";
public static final String ATTR_VISIBILITY = "visibility";
public static final String ATTR_ENABLED = "enabled";
public static final String ATTR_SOURCE_URL = "href";
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";
/**
* 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;
private static final Logger LOG = Logger.getInstance("#org.jetbrains.android.templates.Template");
/**
* Directory within the template which contains the resources referenced
* from the template.xml file
*/
private static final String DATA_ROOT = "root";
/**
* 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;
/**
* 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 template loader which is responsible for finding (and sharing) template files
*/
private final PrefixTemplateLoader myLoader;
private TemplateMetadata myMetadata;
private Project myProject;
private Template(@NotNull File rootPath) {
myTemplateRoot = rootPath;
myLoader = new PrefixTemplateLoader(myTemplateRoot.getPath());
}
/**
* 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));
}
@NotNull
public static Map<String, Object> createParameterMap(@NotNull Map<String, Object> args) {
final Map<String, Object> paramMap = FreemarkerUtils.createParameterMap(args);
// Root folder of the templates
// TODO: This doesn't look like it's used anywhere. Confirm...?
if (ApplicationManager.getApplication() != null && getTemplateRootFolder() != null) {
paramMap.put("templateRoot", getTemplateRootFolder().getAbsolutePath());
}
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);
}
}
/**
* Executes the template, rendering it to output files under the given module root directory.
* Note: This method might cause the creation of a new project to perform certain PSI based operations (Gradle file merging).
* Not only is creating a project expensive, but it performing PSI operations right after a project creation could lead to
* IndexNotReadyException. If you have a project available at call time, use {@link #render(File, File, Map, Project)} instead.
*
* @param outputRootPath the root directory where the template will be expanded.
* @param moduleRootPath 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.
*/
@Deprecated
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. This method will sync the project with
* Gradle if needed.
*
* @param outputRootPath the the root directory where the template will be expanded.
* @param moduleRootPath the 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.
* @see #render(File, File, Map, Project, boolean)
*/
public void render(@NotNull File outputRootPath,
@NotNull File moduleRootPath,
@NotNull Map<String, Object> args,
@Nullable Project project) {
render(outputRootPath, moduleRootPath, args, project, true);
}
/**
* Executes the template, rendering it to output files under the given module root directory.
*
* @param outputRootPath the root directory where the template will be expanded.
* @param moduleRootPath 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.
* @param gradleSyncIfNeeded indicates whether a Gradle sync should be performed if needed.
*/
public void render(@NotNull final File outputRootPath,
@NotNull final File moduleRootPath,
@NotNull final Map<String, Object> args,
@Nullable final Project project,
final boolean gradleSyncIfNeeded) {
assert outputRootPath.isDirectory() : outputRootPath;
WriteCommandAction.runWriteCommandAction(project, new Runnable() {
@Override
public void run() {
doRender(outputRootPath, moduleRootPath, args, project, gradleSyncIfNeeded);
}
});
String title = myMetadata.getTitle();
if (title != null) {
UsageTracker.getInstance()
.trackEvent(UsageTracker.CATEGORY_TEMPLATE, UsageTracker.ACTION_TEMPLATE_RENDER, title, null);
}
}
private void doRender(@NotNull File outputRootPath,
@NotNull File moduleRootPath,
@NotNull Map<String, Object> args,
@Nullable Project project,
boolean gradleSyncIfNeeded) {
myFilesToOpen.clear();
if (project == null) {
// Project creation: no current project to read code style settings from yet, so use defaults
project = ProjectManagerEx.getInstanceEx().getDefaultProject();
}
myProject = project;
Map<String, Object> paramMap = createParameterMap(args);
enforceParameterTypes(getMetadata(), args);
Configuration freemarker = new FreemarkerConfiguration();
freemarker.setTemplateLoader(myLoader);
processFile(freemarker, new File(TEMPLATE_XML_NAME), paramMap, outputRootPath, moduleRootPath, gradleSyncIfNeeded);
}
@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;
}
/**
* Read the given xml file and, if it uses freemarker syntax (indicated by its file extension),
* process the variable definitions
*/
private void processFile(@NotNull final Configuration freemarker,
@NotNull File file,
@NotNull final Map<String, Object> paramMap,
@NotNull final File outputRoot,
@NotNull final File moduleRoot,
final boolean gradleSyncIfNeeded) {
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);
}
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, outputRoot, moduleRoot, gradleSyncIfNeeded);
} // else: <globals> root element
}
else if (TAG_EXECUTE.equals(name)) {
File recipeFile = getPath(attributes, ATTR_FILE);
if (recipeFile != null) {
executeRecipeFile(freemarker, recipeFile, paramMap, outputRoot, moduleRoot, gradleSyncIfNeeded);
}
}
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 fileRecipe,
@NotNull final Map<String, Object> paramMap,
@NotNull File outputRoot,
@NotNull File moduleRoot,
boolean gradleSyncIfNeeded) {
try {
myLoader.setTemplateFile(getTemplateFile(fileRecipe));
String xml = processFreemarkerTemplate(freemarker, paramMap, fileRecipe);
xml = XmlUtils.stripBom(xml);
Recipe recipe = Recipe.parse(new StringReader(xml));
myFilesToOpen.addAll(recipe.getFilesToOpen());
RecipeContext recipeContext = new RecipeContext(myProject, myLoader, freemarker, paramMap, new File(myTemplateRoot, DATA_ROOT), outputRoot, moduleRoot,
gradleSyncIfNeeded);
recipe.execute(recipeContext);
}
catch (Exception e) {
//noinspection AssignmentToStaticFieldFromInstanceMethod
ourMostRecentException = e;
LOG.warn(e);
}
}
@NotNull
private File getTemplateFile(@NotNull File relativeFile) throws IOException {
return new File(myTemplateRoot, relativeFile.getPath());
}
}