package org.infernus.idea.checkstyle;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import com.intellij.openapi.application.PathManager;
import com.intellij.openapi.components.ExportableComponent;
import com.intellij.openapi.components.PersistentStateComponent;
import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.components.State;
import com.intellij.openapi.components.Storage;
import com.intellij.openapi.components.StoragePathMacros;
import com.intellij.openapi.components.StorageScheme;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.infernus.idea.checkstyle.model.ConfigurationLocation;
import org.infernus.idea.checkstyle.model.ConfigurationLocationFactory;
import org.infernus.idea.checkstyle.model.ScanScope;
import org.infernus.idea.checkstyle.util.Notifications;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* A manager for the persistent CheckStyle plug-in configuration. Registered in {@code plugin.xml}.
*/
@State(name = CheckStylePlugin.ID_PLUGIN, storages = {
@Storage(id = "default", file = StoragePathMacros.PROJECT_FILE),
@Storage(id = "dir", file = StoragePathMacros.PROJECT_CONFIG_DIR + "/checkstyle-idea.xml", scheme = StorageScheme.DIRECTORY_BASED)})
public class CheckStyleConfiguration
implements ExportableComponent, PersistentStateComponent<CheckStyleConfiguration.ProjectSettings> {
public static final String PROJECT_DIR = "$PRJ_DIR$";
public static final String LEGACY_PROJECT_DIR = "$PROJECT_DIR$";
private static final Log LOG = LogFactory.getLog(CheckStyleConfiguration.class);
private static final String ACTIVE_CONFIG = "active-configuration";
private static final String CHECKSTYLE_VERSION_SETTING = "checkstyle-version";
private static final String CHECK_TEST_CLASSES = "check-test-classes";
private static final String CHECK_NONJAVA_FILES = "check-nonjava-files";
private static final String SCANSCOPE_SETTING = "scanscope";
private static final String SUPPRESS_ERRORS = "suppress-errors";
private static final String THIRDPARTY_CLASSPATH = "thirdparty-classpath";
private static final String SCAN_BEFORE_CHECKIN = "scan-before-checkin";
private static final String LOCATION_PREFIX = "location-";
private static final String PROPERTIES_PREFIX = "property-";
private final SortedMap<String, String> storage = new ConcurrentSkipListMap<>();
private final ReentrantLock storageLock = new ReentrantLock();
private final List<ConfigurationListener> configurationListeners = Collections.synchronizedList(new ArrayList<>());
private final Project project;
/** mock instance which may be set and used by unit tests */
private static CheckStyleConfiguration sMock = null;
/**
* Create a new configuration bean.
*
* @param project the project we belong to.
*/
public CheckStyleConfiguration(final Project project) {
if (project == null) {
throw new IllegalArgumentException("Project is required");
}
this.project = project;
}
public void addConfigurationListener(final ConfigurationListener configurationListener) {
if (configurationListener != null) {
configurationListeners.add(configurationListener);
}
}
private void fireConfigurationChanged() {
for (ConfigurationListener configurationListener : configurationListeners) {
configurationListener.configurationChanged();
}
}
@NotNull
public File[] getExportFiles() {
return new File[]{PathManager.getOptionsFile("checkstyle-idea_project_settings")};
}
@NotNull
public String getPresentableName() {
return CheckStylePlugin.ID_PLUGIN + " Project Settings";
}
public void setActiveConfiguration(final ConfigurationLocation configurationLocation) {
final List<ConfigurationLocation> configurationLocations = configurationLocations();
if (configurationLocation != null && !configurationLocations.contains(configurationLocation)) {
throw new IllegalArgumentException("Location is not valid: " + configurationLocation);
}
storageLock.lock();
try {
if (configurationLocation != null) {
storage.put(ACTIVE_CONFIG, configurationLocation.getDescriptor());
} else {
storage.remove(ACTIVE_CONFIG);
}
} finally {
storageLock.unlock();
}
}
public ConfigurationLocation getActiveConfiguration() {
storageLock.lock();
try {
if (!storage.containsKey(ACTIVE_CONFIG)) {
return null;
}
ConfigurationLocation activeLocation = null;
try {
activeLocation = configurationLocationFactory().create(getProject(), storage.get(ACTIVE_CONFIG));
} catch (IllegalArgumentException e) {
LOG.warn("Could not load active configuration", e);
}
final List<ConfigurationLocation> configurationLocations = configurationLocations();
if (activeLocation == null || !configurationLocations.contains(activeLocation)) {
LOG.info("Active configuration is invalid, returning null");
return null;
}
setActiveConfiguration(activeLocation);
return activeLocation;
} finally {
storageLock.unlock();
}
}
public List<ConfigurationLocation> getAndResolveConfigurationLocations() {
return setConfigurationLocations(configurationLocations(), false);
}
public List<ConfigurationLocation> configurationLocations() {
storageLock.lock();
try {
return storage.entrySet().stream()
.filter(this::propertyIsALocation)
.map(this::deserialiseLocation)
.filter(this::notNull)
.collect(Collectors.toList());
} finally {
storageLock.unlock();
}
}
private boolean notNull(final Object object) {
return object != null;
}
@Nullable
private ConfigurationLocation deserialiseLocation(final Map.Entry<String, String> locationProperty) {
final String serialisedLocation = locationProperty.getValue();
try {
final ConfigurationLocation location = configurationLocationFactory().create(getProject(),
serialisedLocation);
location.setProperties(propertiesFor(locationProperty));
return location;
} catch (IllegalArgumentException e) {
LOG.error("Could not parse location: " + serialisedLocation, e);
return null;
}
}
private boolean propertyIsALocation(final Map.Entry<String, String> property) {
return property.getKey().startsWith(LOCATION_PREFIX);
}
@NotNull
private Map<String, String> propertiesFor(final Map.Entry<String, String> storageEntry) {
final Map<String, String> properties = new HashMap<>();
final String propertyPrefix = propertyPrefix(storageEntry.getKey());
// loop again over all settings to find the properties belonging to this configuration
// not the best solution, but since there are only few items it doesn't hurt too much...
storage.entrySet().stream().filter(property -> property.getKey().startsWith(propertyPrefix)).forEach(property
-> {
final String propertyName = property.getKey().substring(propertyPrefix.length());
properties.put(propertyName, property.getValue());
});
return properties;
}
@NotNull
private String propertyPrefix(final String key) {
final int index = Integer.parseInt(key.substring(LOCATION_PREFIX.length()));
return PROPERTIES_PREFIX + index + ".";
}
public Project getProject() {
return project;
}
ConfigurationLocationFactory configurationLocationFactory() {
return ServiceManager.getService(getProject(), ConfigurationLocationFactory.class);
}
public void setConfigurationLocations(final List<ConfigurationLocation> configurationLocations) {
setConfigurationLocations(configurationLocations, true);
}
private List<ConfigurationLocation> setConfigurationLocations(final List<ConfigurationLocation>
configurationLocations, final boolean
fireEvents) {
storageLock.lock();
try {
removeUnknownProperties();
if (configurationLocations == null) {
return Collections.emptyList();
}
int index = 0;
for (ConfigurationLocation configurationLocation : configurationLocations) {
storage.put(LOCATION_PREFIX + index, configurationLocation.getDescriptor());
try {
final Map<String, String> properties = configurationLocation.getProperties();
if (properties != null) {
for (Map.Entry<String, String> entry : properties.entrySet()) {
String value = entry.getValue();
if (value == null) {
value = "";
}
storage.put(PROPERTIES_PREFIX + index + "." + entry.getKey(), value);
}
}
} catch (IOException e) {
LOG.error("Failed to read properties from " + configurationLocation, e);
Notifications.showError(getProject(), CheckStyleBundle.message("checkstyle" + ""
+ ".could-not-read-properties", configurationLocation.getLocation()));
}
++index;
}
if (fireEvents) {
fireConfigurationChanged();
}
return configurationLocations;
} finally {
storageLock.unlock();
}
}
private void removeUnknownProperties() {
for (final Iterator i = storage.keySet().iterator(); i.hasNext(); ) {
final String propertyName = i.next().toString();
if (propertyName.startsWith(LOCATION_PREFIX) || propertyName.startsWith(PROPERTIES_PREFIX)) {
i.remove();
}
}
}
@NotNull
public List<String> getThirdPartyClassPath() {
final List<String> thirdPartyClasspath = new ArrayList<>();
final String value = storage.get(THIRDPARTY_CLASSPATH);
if (value != null) {
final String[] parts = value.split(";");
for (final String part : parts) {
if (part.length() > 0) {
thirdPartyClasspath.add(untokenisePath(part));
}
}
}
return thirdPartyClasspath;
}
public void setThirdPartyClassPath(final List<String> value) {
if (value == null) {
storage.remove(THIRDPARTY_CLASSPATH);
return;
}
final StringBuilder valueString = new StringBuilder();
for (final String part : value) {
if (valueString.length() > 0) {
valueString.append(";");
}
valueString.append(tokenisePath(part));
}
storage.put(THIRDPARTY_CLASSPATH, valueString.toString());
}
public String getCheckstyleVersion(@Nullable final String defaultVersion) {
String result = storage.get(CHECKSTYLE_VERSION_SETTING);
if (result == null) {
if (defaultVersion != null) {
result = defaultVersion;
} else {
CheckstyleProjectService csService = CheckstyleProjectService.getInstance(project);
result = csService.getDefaultVersion();
}
}
return result;
}
public void setCheckstyleVersion(@Nullable final String pVersion) {
String v = pVersion;
if (pVersion == null) {
CheckstyleProjectService csService = CheckstyleProjectService.getInstance(project);
v = csService.getDefaultVersion();
}
storage.put(CHECKSTYLE_VERSION_SETTING, v);
}
@NotNull
public ScanScope getScanScope() {
return scopeValueOf(SCANSCOPE_SETTING);
}
public void setScanScope(@Nullable final ScanScope pScanScope) {
storage.put(SCANSCOPE_SETTING, pScanScope != null ? pScanScope.name() : ScanScope.getDefaultValue().name());
}
public boolean isSuppressingErrors() {
return booleanValueOf(SUPPRESS_ERRORS);
}
public void setSuppressingErrors(final boolean suppressingErrors) {
save(SUPPRESS_ERRORS, suppressingErrors);
}
public boolean isScanFilesBeforeCheckin() {
return booleanValueOf(SCAN_BEFORE_CHECKIN);
}
public void setScanFilesBeforeCheckin(final boolean scanFilesBeforeCheckin) {
save(SCAN_BEFORE_CHECKIN, scanFilesBeforeCheckin);
}
private void save(final String propertyName, final boolean propertyValue) {
storage.put(propertyName, Boolean.toString(propertyValue));
}
private boolean booleanValueOf(final String propertyName) {
final String propertyValue = storage.get(propertyName);
return propertyValue != null && Boolean.valueOf(propertyValue);
}
@NotNull
private ScanScope scopeValueOf(final String propertyName) {
final String propertyValue = storage.get(propertyName);
ScanScope result = ScanScope.getDefaultValue();
if (propertyValue != null) {
try {
result = ScanScope.valueOf(propertyValue);
} catch (IllegalArgumentException e) {
// settings got messed up (manual edit?) - use default
}
}
return result;
}
/**
* Process a stored file path for any tokens.
*
* @param path the path to process.
* @return the processed path.
*/
private String untokenisePath(final String path) {
if (path == null) {
return null;
}
LOG.debug("Processing file: " + path);
for (String prefix : new String[]{PROJECT_DIR, LEGACY_PROJECT_DIR}) {
if (path.startsWith(prefix)) {
return untokeniseForPrefix(path, prefix, getProjectPath());
}
}
return path;
}
private String untokeniseForPrefix(final String path, final String prefix, final File projectPath) {
if (projectPath != null) {
final File fullConfigFile = new File(projectPath, path.substring(prefix.length()));
return fullConfigFile.getAbsolutePath();
}
LOG.warn("Could not untokenise path as project dir is unset: " + path);
return path;
}
/**
* Process a path and add tokens as necessary.
*
* @param path the path to processed.
* @return the tokenised path.
*/
private String tokenisePath(final String path) {
if (path == null) {
return null;
}
final File projectPath = getProjectPath();
if (projectPath != null) {
final String projectPathAbs = projectPath.getAbsolutePath();
if (path.startsWith(projectPathAbs)) {
return PROJECT_DIR + path.substring(projectPathAbs.length());
}
}
return path;
}
/**
* Get the base path of the project.
*
* @return the base path of the project.
*/
@Nullable
private File getProjectPath() {
final VirtualFile baseDir = getProject().getBaseDir();
if (baseDir == null) {
return null;
}
return new File(baseDir.getPath());
}
/**
* Create a copy of the current configuration.
*
* @return a copy of the current configuration settings
*/
public ProjectSettings getState() {
storageLock.lock();
try {
return new ProjectSettings(storage);
} finally {
storageLock.unlock();
}
}
/**
* Load the state from the given settings beans.
*
* @param projectSettings the project settings to load.
*/
public void loadState(final ProjectSettings projectSettings) {
storageLock.lock();
try {
storage.clear();
if (projectSettings != null) {
Map<String, String> loadedMap = projectSettings.configurationAsMap();
convertSettingsFormat(loadedMap);
storage.putAll(loadedMap);
}
} finally {
storageLock.unlock();
}
}
/**
* Needed when a setting written by a previous version of this plugin gets loaded by a newer version; converts
* the scan scope settings based on flags to the enum value.
*
* @param pLoadedMap the loaded settings
*/
private void convertSettingsFormat(final Map<String, String> pLoadedMap) {
if (pLoadedMap != null && !pLoadedMap.isEmpty() && !pLoadedMap.containsKey(SCANSCOPE_SETTING)) {
ScanScope scope = ScanScope.fromFlags(booleanValueOf(CHECK_TEST_CLASSES), booleanValueOf(CHECK_NONJAVA_FILES));
pLoadedMap.put(SCANSCOPE_SETTING, scope.name());
pLoadedMap.remove(CHECK_TEST_CLASSES);
pLoadedMap.remove(CHECK_NONJAVA_FILES);
}
}
/**
* Wrapper class for IDEA state serialisation.
*/
public static class ProjectSettings {
// this must remain public for serialisation purposes
public Map<String, String> configuration;
public ProjectSettings() {
this.configuration = new TreeMap<>();
}
public ProjectSettings(final Map<String, String> configuration) {
this.configuration = new TreeMap<>(configuration);
}
public Map<String, String> configurationAsMap() {
if (configuration == null) {
return Collections.emptyMap();
}
return configuration;
}
}
public static CheckStyleConfiguration getInstance(@NotNull final Project pProject) {
CheckStyleConfiguration result = sMock;
if (result == null) {
result = ServiceManager.getService(pProject, CheckStyleConfiguration.class);
}
return result;
}
public static void activateMock4UnitTesting(@Nullable final CheckStyleConfiguration pMock) {
sMock = pMock;
}
}