blob: 82d62bf54352e4e1a1703de60c40e6de07c41824 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.netbeans.modules.java.lsp.server.protocol;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.SyncFailedException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Map.Entry;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.openide.filesystems.*;
import org.openide.modules.Places;
import org.openide.util.EditableProperties;
import org.openide.util.Exceptions;
import org.openide.util.NbBundle;
final class OptionsExportModel {
private static final Logger LOGGER = Logger.getLogger(OptionsExportModel.class.getName());
/** Folder in layer file system where provider are searched for. */
private static final String OPTIONS_EXPORT_FOLDER = "OptionsExport"; //NOI18N
/** Pattern used to get names of option profiles. */
private static final String GROUP_PATTERN = "([^/]*)"; //NOI18N
private static final List<String> ENABLED_CATEGORIES = Collections.singletonList("Formatting"); //NOI18N
private static OptionsExportModel SINGLETON = new OptionsExportModel();
/** Target userdir for import. */
private final File targetUserdir = Places.getUserDirectory();
/** Source of export/import (zip file or userdir). */
private File source;
/** List of categories. */
private List<Category> categories;
/** Cache of paths relative to source root. */
List<String> relativePaths;
/** Include patterns. */
private Set<String> includePatterns;
/** Exclude patterns. */
private Set<String> excludePatterns;
/** Properties currently being copied. */
private EditableProperties currentProperties;
/** List of ignored folders in userdir. It speeds up folder scanning. */
private static final List<String> IGNORED_FOLDERS = Arrays.asList("var/cache"); // NOI18N
/** Returns instance of export options model.
* @param source source of export/import. It is either zip file or userdir
* @return instance of export options model
*/
private OptionsExportModel() {
}
static OptionsExportModel get() {
return SINGLETON;
}
void doImport(File source) throws IOException {
LOGGER.log(Level.FINE, "Copying from: {0}\n to: {1}", new Object[]{source, targetUserdir}); //NOI18N
this.source = source;
this.relativePaths = null;
try (ZipFile zipFile = new ZipFile(source)) {
// Enumerate each entry
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry zipEntry = entries.nextElement();
if (!zipEntry.isDirectory()) {
copyFile(zipEntry.getName());
}
}
}
}
void clean() throws IOException {
this.source = null;
this.relativePaths = null;
for (String relativePath : getRelativePaths()) {
clearFile(relativePath);
}
}
private List<Category> getCategories() {
if (categories == null) {
loadCategories();
}
return categories;
}
/** Copies files from source (zip file or userdir) to target dir according
* to current state of model, i.e. only include/exclude patterns from
* enabled items are considered.
* @param targetUserdir target userdir
*/
private static enum ParserState {
START,
IN_KEY_PATTERN,
AFTER_KEY_PATTERN,
IN_BLOCK
}
/** Parses given compound string pattern into set of single patterns.
* @param pattern compound pattern in form filePattern1#keyPattern1#|filePattern2#keyPattern2#|filePattern3
* @return set of single patterns containing just one # (e.g. [filePattern1#keyPattern1, filePattern2#keyPattern2, filePattern3])
*/
static Set<String> parsePattern(String pattern) {
Set<String> patterns = new HashSet<String>();
if (pattern.contains("#")) { //NOI18N
StringBuilder partPattern = new StringBuilder();
ParserState state = ParserState.START;
int blockLevel = 0;
for (int i = 0; i < pattern.length(); i++) {
char c = pattern.charAt(i);
switch(state) {
case START:
if (c == '#') {
state = ParserState.IN_KEY_PATTERN;
partPattern.append(c);
} else if (c == '(') {
state = ParserState.IN_BLOCK;
blockLevel++;
partPattern.append(c);
} else if (c == '|') {
patterns.add(partPattern.toString());
partPattern = new StringBuilder();
} else {
partPattern.append(c);
}
break;
case IN_KEY_PATTERN:
if (c == '#') {
state = ParserState.AFTER_KEY_PATTERN;
} else {
partPattern.append(c);
}
break;
case AFTER_KEY_PATTERN:
if (c == '|') {
state = ParserState.START;
patterns.add(partPattern.toString());
partPattern = new StringBuilder();
} else {
assert false : "Wrong OptionsExport pattern " + pattern + ". Only format like filePattern1#keyPattern#|filePattern2 is supported."; //NOI18N
}
break;
case IN_BLOCK:
partPattern.append(c);
if (c == ')') {
blockLevel--;
if (blockLevel == 0) {
state = ParserState.START;
}
}
break;
}
}
patterns.add(partPattern.toString());
} else {
patterns.add(pattern);
}
return patterns;
}
/** Returns set of include patterns. */
private synchronized Set<String> getIncludePatterns() {
if (includePatterns == null) {
Set<String> patterns = new HashSet<>();
for (OptionsExportModel.Category category : getCategories()) {
for (OptionsExportModel.Item item : category.getItems()) {
if (item.isEnabled()) {
String include = item.getInclude();
if (include != null && include.length() > 0) {
patterns.addAll(parsePattern(include));
}
}
}
}
includePatterns = patterns;
}
return includePatterns;
}
/** Returns set of exclude patterns. */
private synchronized Set<String> getExcludePatterns() {
if (excludePatterns == null) {
Set<String> patterns = new HashSet<>();
for (OptionsExportModel.Category category : getCategories()) {
for (OptionsExportModel.Item item : category.getItems()) {
if (item.isEnabled()) {
String exclude = item.getExclude();
if (exclude != null && exclude.length() > 0) {
patterns.addAll(parsePattern(exclude));
}
}
}
}
excludePatterns = patterns;
}
return excludePatterns;
}
/** Represents one item and hold include/exclude patterns. */
private class Item {
private final String include;
private final String exclude;
private boolean enabled = false;
private Item(String include, String exclude) {
this.include = include;
this.exclude = exclude;
assert assertIgnoredFolders(include);
}
private String getInclude() {
return include;
}
private String getExclude() {
return exclude;
}
private boolean isEnabled() {
return enabled;
}
private void setEnabled(boolean newState) {
if (enabled != newState) {
enabled = newState;
// reset cached patterns
includePatterns = null;
excludePatterns = null;
}
}
/** Check that IGNORED_FOLDERS doesn't contain given pattern. */
private boolean assertIgnoredFolders(String pattern) {
boolean result = true;
for (String folder : IGNORED_FOLDERS) {
assert result = !pattern.contains(folder) : "Pattern " + pattern + " matches ignored folder " + folder;
}
return result;
}
}
/** Represents category holding several items. */
private class Category {
//xml entry names
private static final String INCLUDE = "include"; // NOI18N
private static final String EXCLUDE = "exclude"; // NOI18N
private final FileObject categoryFO;
private List<Item> items;
private Category(FileObject fo) {
this.categoryFO = fo;
}
private void addItem(String includes, String excludes) {
items.add(new Item(includes, excludes));
}
private void resolveGroups(String include, String exclude) {
LOGGER.log(Level.FINE, "resolveGroups include={0}", include); //NOI18N
List<String> applicablePaths = getApplicablePaths(
Collections.singleton(include),
Collections.singleton(exclude));
Set<String> groups = new HashSet<>();
Pattern p = Pattern.compile(include);
for (String path : applicablePaths) {
Matcher m = p.matcher(path);
m.matches();
if (m.groupCount() == 1) {
String group = m.group(1);
if (group != null) {
groups.add(group);
}
}
}
LOGGER.log(Level.FINE, "GROUPS={0}", groups); //NOI18N
for (String group : groups) {
// add additional items according to groups
addItem(include.replace(GROUP_PATTERN, group), exclude);
}
}
private List<Item> getItems() {
if (items == null) {
items = Collections.synchronizedList(new ArrayList<>());
FileObject[] itemsFOs = categoryFO.getChildren();
// respect ordering defined in layers
List<FileObject> sortedItems = FileUtil.getOrder(Arrays.asList(itemsFOs), false);
itemsFOs = sortedItems.toArray(new FileObject[0]);
for (FileObject itemFO : itemsFOs) {
String include = (String) itemFO.getAttribute(INCLUDE);
if (include == null) {
include = ""; //NOI18N
}
String exclude = (String) itemFO.getAttribute(EXCLUDE);
if (exclude == null) {
exclude = ""; //NOI18N
}
if (include.contains(GROUP_PATTERN)) {
resolveGroups(include, exclude);
} else {
addItem(include, exclude);
}
}
}
return items;
}
private String getName() {
return categoryFO.getNameExt();
}
private void setEnabled(boolean enabled) {
for (Item item : getItems()) {
item.setEnabled(enabled);
}
}
} // end of Category
/** Load categories from filesystem. */
private void loadCategories() {
FileObject[] categoryFOs = FileUtil.getConfigFile(OPTIONS_EXPORT_FOLDER).getChildren();
// respect ordering defined in layers
List<FileObject> sortedCats = FileUtil.getOrder(Arrays.asList(categoryFOs), false);
categories = new ArrayList<>(sortedCats.size());
for (FileObject curFO : sortedCats) {
Category category = new Category(curFO);
if (ENABLED_CATEGORIES.contains(category.getName())) {
category.setEnabled(true);
}
categories.add(category);
}
}
/** Filters relative paths of current source and returns only ones which match given
* include/exclude patterns.
* @param includePatterns include patterns
* @param excludePatterns exclude patterns
* @return relative patsh which match include/exclude patterns
*/
private List<String> getApplicablePaths(Set<String> includePatterns, Set<String> excludePatterns) {
List<String> applicablePaths = new ArrayList<>();
for (String relativePath : getRelativePaths()) {
if (matches(relativePath, includePatterns, excludePatterns)) {
applicablePaths.add(relativePath);
}
}
return applicablePaths;
}
private List<String> getRelativePaths() {
if (relativePaths == null) {
if (source != null && source.isFile()) {
try {
// zip file
relativePaths = listZipFile(source);
} catch (IOException ex) {
Exceptions.attachLocalizedMessage(ex, NbBundle.getMessage(OptionsExportModel.class, "OptionsExportModel.invalid.zipfile", source));
Exceptions.printStackTrace(ex);
relativePaths = Collections.emptyList();
}
} else {
// userdir
File root = FileUtil.toFile(FileUtil.getConfigRoot());
relativePaths = getRelativePaths(Places.getUserDirectory());
}
LOGGER.fine("relativePaths=" + relativePaths); //NOI18N
}
return relativePaths;
}
/** Returns list of file path relative to given source root. It scans
* sub folders recursively.
* @param sourceRoot source root
* @return list of file path relative to given source root
*/
private static List<String> getRelativePaths(File sourceRoot) {
return getRelativePaths(sourceRoot, sourceRoot);
}
private static List<String> getRelativePaths(File root, File file) {
String relativePath = getRelativePath(root, file);
List<String> result = new ArrayList<>();
if (file.isDirectory()) {
if (IGNORED_FOLDERS.contains(relativePath)) {
return result;
}
File[] children = file.listFiles();
if (children == null) {
return Collections.emptyList();
}
for (File child : children) {
result.addAll(getRelativePaths(root, child));
}
} else {
result.add(relativePath);
}
return result;
}
/** Returns slash separated path relative to given root. */
private static String getRelativePath(File root, File file) {
String result = file.getAbsolutePath().substring(root.getAbsolutePath().length());
result = result.replace('\\', '/'); //NOI18N
if (result.startsWith("/") && !result.startsWith("//")) { //NOI18N
result = result.substring(1);
}
return result;
}
/** Returns true if given relative path matches at least one of given include
* patterns and doesn't match all exclude patterns.
* @param relativePath relative path
* @param includePatterns include patterns
* @param excludePatterns exclude patterns
* @return true if given relative path matches at least one of given include
* patterns and doesn't match all exclude patterns, false otherwise
*/
private static boolean matches(String relativePath, Set<String> includePatterns, Set<String> excludePatterns) {
boolean include = false;
for (String pattern : includePatterns) {
if (matches(relativePath, pattern)) {
include = true;
break;
}
}
if (include) {
// check excludes
for (String pattern : excludePatterns) {
if (!pattern.contains("#") && matches(relativePath, pattern)) {
return false;
}
}
}
return include;
}
/** Returns true if given relative path matches pattern.
* @param relativePath relative path
* @param pattern regex pattern. If contains #, only part before # is taken
* into account
* @return true if given relative path matches pattern.
*/
private static boolean matches(String relativePath, String pattern) {
if (pattern.contains("#")) { //NOI18N
pattern = pattern.split("#", 2)[0]; //NOI18N
}
return relativePath.matches(pattern);
}
/** Returns set of keys matching given pattern.
* @param relativePath path relative to sourceRoot
* @param propertiesPattern pattern like file.properties#keyPattern
* @return set of matching keys, never null
* @throws IOException if properties cannot be loaded
*/
private Set<String> matchingKeys(String relativePath, String propertiesPattern) throws IOException {
Set<String> matchingKeys = new HashSet<String>();
String[] patterns = propertiesPattern.split("#", 2);
String filePattern = patterns[0];
String keyPattern = patterns[1];
if (relativePath.matches(filePattern)) {
if (currentProperties == null) {
currentProperties = getProperties(relativePath);
}
for (String key : currentProperties.keySet()) {
if (key.matches(keyPattern)) {
matchingKeys.add(key);
}
}
}
return matchingKeys;
}
/** Copy file given by relative path from source zip to target userdir.
* It creates necessary sub folders.
* @param relativePath relative path
* @throws java.io.IOException if copying fails
*/
private void copyFile(String relativePath) throws IOException {
currentProperties = null;
boolean includeFile = false; // include? entire file
Set<String> includeKeys = new HashSet<>();
Set<String> excludeKeys = new HashSet<>();
for (String pattern : getIncludePatterns()) {
if (pattern.contains("#")) { //NOI18N
includeKeys.addAll(matchingKeys(relativePath, pattern));
} else {
if (relativePath.matches(pattern)) {
includeFile = true;
includeKeys.clear(); // include entire file
break;
}
}
}
if (includeFile || !includeKeys.isEmpty()) {
// check excludes
for (String pattern : getExcludePatterns()) {
if (pattern.contains("#")) { //NOI18N
excludeKeys.addAll(matchingKeys(relativePath, pattern));
} else {
if (relativePath.matches(pattern)) {
includeFile = false;
includeKeys.clear(); // exclude entire file
break;
}
}
}
}
LOGGER.log(Level.FINEST, "{0}, includeFile={1}, includeKeys={2}, excludeKeys={3}", new Object[]{relativePath, includeFile, includeKeys, excludeKeys}); //NOI18N
if (!includeFile && includeKeys.isEmpty()) {
// nothing matches
return;
}
File targetFile = new File(targetUserdir, relativePath);
File origFile = new File(targetUserdir, relativePath + ".orig");
if (!origFile.exists()) {
// copy original file
try (OutputStream out = createOutputStream(origFile)) {
copyFile(relativePath, out);
}
}
LOGGER.log(Level.FINE, "Path: {0}", relativePath); //NOI18N
if (includeKeys.isEmpty() && excludeKeys.isEmpty()) {
// copy entire file
try (OutputStream out = createOutputStream(targetFile)) {
copyFile(relativePath, out);
}
} else {
mergeProperties(relativePath, includeKeys, excludeKeys);
}
}
/** Clears file given by relative path in target userdir.
* @param relativePath relative path
* @throws java.io.IOException if clear fails
*/
private void clearFile(String relativePath) throws IOException {
boolean includeFile = false; // include? entire file
Set<String> includeKeys = new HashSet<>();
Set<String> excludeKeys = new HashSet<>();
for (String pattern : getIncludePatterns()) {
if (pattern.contains("#")) { //NOI18N
includeKeys.addAll(matchingKeys(relativePath, pattern));
} else {
if (relativePath.matches(pattern)) {
includeFile = true;
includeKeys.clear(); // include entire file
break;
}
}
}
if (includeFile || !includeKeys.isEmpty()) {
// check excludes
for (String pattern : getExcludePatterns()) {
if (pattern.contains("#")) { //NOI18N
excludeKeys.addAll(matchingKeys(relativePath, pattern));
} else {
if (relativePath.matches(pattern)) {
includeFile = false;
includeKeys.clear(); // exclude entire file
break;
}
}
}
}
LOGGER.log(Level.FINEST, "{0}, includeFile={1}, includeKeys={2}, excludeKeys={3}", new Object[]{relativePath, includeFile, includeKeys, excludeKeys}); //NOI18N
if (!includeFile && includeKeys.isEmpty()) {
// nothing matches
return;
}
LOGGER.log(Level.FINE, "Path: {0}", relativePath); //NOI18N
File targetFile = new File(targetUserdir, relativePath);
File origFile = new File(targetUserdir, relativePath + ".orig");
if (origFile.exists()) {
// copy original file
try (OutputStream out = createOutputStream(targetFile)) {
copyFile(relativePath + ".orig", out);
} catch (IOException ioe) {
Exceptions.printStackTrace(ioe);
}
origFile.delete();
}
}
/** Merge source properties to existing target properties.
* @param relativePath relative path
* @param includeKeys keys to include
* @param excludeKeys keys to exclude
* @throws IOException if I/O fails
*/
private void mergeProperties(String relativePath, Set<String> includeKeys, Set<String> excludeKeys) throws IOException {
if (!includeKeys.isEmpty()) {
currentProperties.keySet().retainAll(includeKeys);
}
currentProperties.keySet().removeAll(excludeKeys);
LOGGER.log(Level.FINE, " Keys merged with existing properties: {0}", currentProperties.keySet()); //NOI18N
if (currentProperties.isEmpty()) {
return;
}
EditableProperties targetProperties = new EditableProperties(false);
InputStream in = null;
File targetFile = new File(targetUserdir, relativePath);
try {
if (targetFile.exists()) {
in = new FileInputStream(targetFile);
targetProperties.load(in);
}
} finally {
if (in != null) {
in.close();
}
}
for (Entry<String, String> entry : currentProperties.entrySet()) {
targetProperties.put(entry.getKey(), entry.getValue());
}
try (OutputStream out = createOutputStream(targetFile)) {
targetProperties.store(out);
}
}
/** Returns properties from relative path in zip or userdir.
* @param relativePath relative path
* @return properties from relative path in zip or userdir.
* @throws IOException if cannot open stream
*/
private EditableProperties getProperties(String relativePath) throws IOException {
EditableProperties properties = new EditableProperties(false);
InputStream in = null;
try {
in = getInputStream(relativePath);
properties.load(in);
} finally {
if (in != null) {
in.close();
}
}
return properties;
}
/** Returns InputStream from relative path in zip file or userdir.
* @param relativePath relative path
* @return InputStream from relative path in zip file or userdir.
* @throws IOException if stream cannot be open
*/
private InputStream getInputStream(String relativePath) throws IOException {
if (source != null && source.isFile()) {
//zip file
ZipFile zipFile = new ZipFile(source);
ZipEntry zipEntry = zipFile.getEntry(relativePath);
return zipFile.getInputStream(zipEntry);
} else {
// userdir
return new FileInputStream(new File(Places.getUserDirectory(), relativePath));
}
}
/** Copy file from relative path in zip file or userdir to target OutputStream.
* @param relativePath relative path
* @param out output stream
* @throws java.io.IOException if copying fails
*/
private void copyFile(String relativePath, OutputStream out) throws IOException {
try (InputStream in = getInputStream(relativePath)) {
FileUtil.copy(in, out);
}
}
/** Creates parent of given file, if doesn't exist. */
private static void ensureParent(File file) throws IOException {
final File parent = file.getParentFile();
if (parent != null && !parent.exists()) {
if (!parent.mkdirs()) {
throw new IOException("Cannot create folder: " + parent.getAbsolutePath()); //NOI18N
}
}
}
/** Returns list of paths from given zip file.
* @param file zip file
* @return list of paths from given zip file
* @throws java.io.IOException
*/
private static List<String> listZipFile(File file) throws IOException {
List<String> relativePaths = new ArrayList<>();
// Open the ZIP file
ZipFile zipFile = new ZipFile(file);
// Enumerate each entry
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry zipEntry = (ZipEntry) entries.nextElement();
if (!zipEntry.isDirectory()) {
relativePaths.add(zipEntry.getName());
}
}
return relativePaths;
}
private static OutputStream createOutputStream(File file) throws IOException {
if (containsConfig(file)) {
file = file.getCanonicalFile();
File root = FileUtil.toFile(FileUtil.getConfigRoot());
String filePath = file.getPath();
String rootPath = root.getPath();
if (filePath.startsWith(rootPath)) {
String res = filePath.substring(rootPath.length()).replace(File.separatorChar, '/');
FileObject fo;
try {
fo = FileUtil.createData(FileUtil.getConfigRoot(), res);
if (fo != null) {
return fo.getOutputStream();
}
} catch (SyncFailedException ex) {
LOGGER.log(Level.INFO, "File already exists: {0}", filePath); //NOI18N
} catch (IOException ex) {
LOGGER.log(Level.INFO, "IOException while getting output stream: {0}", filePath); //NOI18N
}
}
}
ensureParent(file);
return new FileOutputStream(file);
}
private static boolean containsConfig(File file) {
for (;;) {
if (file == null) {
return false;
}
if (file.getName().equals("config")) {
return true;
}
file = file.getParentFile();
}
}
}