package com.haskforce.settings;
import com.haskforce.ui.JTextAccessorField;
import com.haskforce.utils.ExecUtil;
import com.haskforce.utils.GuiUtil;
import com.haskforce.utils.NotificationUtil;
import com.intellij.ide.util.PropertiesComponent;
import com.intellij.notification.NotificationType;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.options.ConfigurationException;
import com.intellij.openapi.options.SearchableConfigurable;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.TextFieldWithBrowseButton;
import com.intellij.openapi.util.Pair;
import com.intellij.ui.JBColor;
import com.intellij.ui.RawCommandLineEditor;
import com.intellij.ui.TextAccessor;
import com.intellij.util.messages.Topic;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import scala.runtime.AbstractFunction1;
import javax.swing.*;
import java.awt.*;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* The "Haskell Tools" option in Preferences->Project Settings.
*/
public class HaskellToolsConfigurable implements SearchableConfigurable {
public static final String HASKELL_TOOLS_ID = "Haskell Tools";
private static final Logger LOG = Logger.getInstance(HaskellToolsConfigurable.class);
private PropertiesComponent propertiesComponent;
// Swing components.
private JPanel mainPanel;
private TextFieldWithBrowseButton stylishPath;
private RawCommandLineEditor stylishFlags;
private JButton stylishAutoFind;
private JTextField stylishVersion;
private TextFieldWithBrowseButton hlintPath;
private RawCommandLineEditor hlintFlags;
private JButton hlintAutoFind;
private JTextField hlintVersion;
private TextFieldWithBrowseButton ghcModPath;
private RawCommandLineEditor ghcModFlags;
private JButton ghcModAutoFind;
private JTextField ghcModVersion;
private TextFieldWithBrowseButton ghcModiPath;
private JButton ghcModiAutoFind;
private JTextField ghcModiVersion;
private RawCommandLineEditor ghcModiFlags;
private JTextAccessorField ghcModiTimeout;
private TextFieldWithBrowseButton hindentPath;
private JButton hindentAutoFind;
private JTextField hindentVersion;
private RawCommandLineEditor hindentFlags;
private List<Property> properties;
public HaskellToolsConfigurable(@NotNull Project project) {
this.propertiesComponent = PropertiesComponent.getInstance(project);
properties = Arrays.asList(
new Tool(project, "stylish-haskell", ToolKey.STYLISH_HASKELL_KEY, stylishPath, stylishFlags,
stylishAutoFind, stylishVersion, "--help"),
new Tool(project, "hlint", ToolKey.HLINT_KEY, hlintPath, hlintFlags,
hlintAutoFind, hlintVersion),
new Tool(project, "ghc-mod", ToolKey.GHC_MOD_KEY, ghcModPath, ghcModFlags,
ghcModAutoFind, ghcModVersion, "version"),
new Tool(project, "ghc-modi", ToolKey.GHC_MODI_KEY, ghcModiPath, ghcModiFlags,
ghcModiAutoFind, ghcModiVersion, "version", SettingsChangeNotifier.GHC_MODI_TOPIC),
new PropertyField(ToolKey.GHC_MODI_TIMEOUT_KEY, ghcModiTimeout, Long.toString(ToolKey.getGhcModiTimeout(project))),
new Tool(project, "hindent", ToolKey.HINDENT_KEY, hindentPath, hindentFlags,
hindentAutoFind, hindentVersion)
);
// Validate that we can only enter numbers in the timeout field.
final Color originalBackground = ghcModiTimeout.getBackground();
ghcModiTimeout.setInputVerifier(new InputVerifier() {
@Override
public boolean verify(JComponent input) {
JTextAccessorField field = (JTextAccessorField)input;
try {
//noinspection ResultOfMethodCallIgnored
Long.parseLong(field.getText());
} catch (NumberFormatException e) {
field.setBackground(JBColor.RED);
return false;
}
field.setBackground(originalBackground);
return true;
}
});
}
interface Property {
boolean isModified();
void saveState();
void restoreState();
}
interface Versioned {
void updateVersion();
}
/**
* Manages the state of a PropertyComponent and its respective field.
*/
class PropertyField implements Property {
public String oldValue;
public String propertyKey;
public final TextAccessor field;
PropertyField(@NotNull String propertyKey, @NotNull TextAccessor field) {
this(propertyKey, field, "");
}
PropertyField(@NotNull String propertyKey, @NotNull TextAccessor field, @NotNull String defaultValue) {
this.propertyKey = propertyKey;
this.field = field;
this.oldValue = propertiesComponent.getValue(propertyKey, defaultValue);
field.setText(oldValue);
}
public boolean isModified() {
return !field.getText().equals(oldValue);
}
public void saveState() {
propertiesComponent.setValue(propertyKey, oldValue = field.getText());
}
public void restoreState() {
field.setText(oldValue);
}
}
/**
* Manages the group of fields which reside to a particular tool.
*/
class Tool implements Property, Versioned {
public final Project project;
public final String command;
public final ToolKey key;
public final TextFieldWithBrowseButton pathField;
public final RawCommandLineEditor flagsField;
public final JTextField versionField;
public final String versionParam;
public final JButton autoFindButton;
public final List<PropertyField> propertyFields;
public final @Nullable Topic<SettingsChangeNotifier> topic;
private final @Nullable SettingsChangeNotifier publisher;
Tool(Project project, String command, ToolKey key, TextFieldWithBrowseButton pathField,
RawCommandLineEditor flagsField, JButton autoFindButton, JTextField versionField) {
this(project, command, key, pathField, flagsField, autoFindButton, versionField, "--version");
}
Tool(Project project, String command, ToolKey key, TextFieldWithBrowseButton pathField,
RawCommandLineEditor flagsField, JButton autoFindButton, JTextField versionField, String versionParam) {
this(project, command, key, pathField, flagsField, autoFindButton, versionField, versionParam, null);
}
Tool(Project project, String command, ToolKey key, TextFieldWithBrowseButton pathField,
RawCommandLineEditor flagsField, JButton autoFindButton, JTextField versionField, String versionParam,
@Nullable Topic<SettingsChangeNotifier> topic) {
this.project = project;
this.command = command;
this.key = key;
this.pathField = pathField;
this.flagsField = flagsField;
this.versionField = versionField;
this.versionParam = versionParam;
this.autoFindButton = autoFindButton;
this.topic = topic;
this.publisher = topic == null ? null : project.getMessageBus().syncPublisher(topic);
this.propertyFields = Arrays.asList(
new PropertyField(key.pathKey, pathField),
new PropertyField(key.flagsKey, flagsField));
GuiUtil.addFolderListener(pathField, command);
GuiUtil.addApplyPathAction(autoFindButton, pathField, command);
updateVersion();
}
public void updateVersion() {
String pathText = pathField.getText();
if (pathText.isEmpty()) {
versionField.setText("");
} else {
// Get the first line reported from `getVersion`
String v = getVersion(pathText, versionParam);
if (v == null) return;
String[] lines = NEWLINE_REGEX.split(v);
if (lines.length == 0) return;
versionField.setText(lines[0]);
}
}
private Pattern NEWLINE_REGEX = Pattern.compile("\r\n|\r|\n");
public boolean isModified() {
for (PropertyField propertyField : propertyFields) {
if (propertyField.isModified()) {
return true;
}
}
return false;
}
public void saveState() {
if (isModified() && publisher != null) {
publisher.onSettingsChanged(new ToolSettings(pathField.getText(), flagsField.getText()));
}
for (PropertyField propertyField : propertyFields) {
propertyField.saveState();
}
}
public void restoreState() {
for (PropertyField propertyField : propertyFields) {
propertyField.restoreState();
}
}
}
@NotNull
@Override
public String getId() {
return HASKELL_TOOLS_ID;
}
@Nullable
@Override
public Runnable enableSearch(String s) {
// TODO
return null;
}
@Nls
@Override
public String getDisplayName() {
return HASKELL_TOOLS_ID;
}
@Nullable
@Override
public String getHelpTopic() {
return null;
}
@Nullable
@Override
public JComponent createComponent() {
return mainPanel;
}
/**
* Enables the apply button if anything changed.
*/
@Override
public boolean isModified() {
for (Property property : properties) {
if (property.isModified()) {
return true;
}
}
return false;
}
/**
* Triggered when the user pushes the apply button.
*/
@Override
public void apply() throws ConfigurationException {
validate();
updateVersionInfoFields();
saveState();
}
public void validate() throws ConfigurationException {
validateExecutableIfNonEmpty("stylish", stylishPath);
validateExecutableIfNonEmpty("hlint", hlintPath);
// Validate ghcModPath if either it or ghcModiPath have been set.
if (ghcModPath.getText().isEmpty() && !ghcModiPath.getText().isEmpty()) {
throw new ConfigurationException("ghc-mod must be configured if ghc-modi is configured.");
}
validateExecutableIfNonEmpty("ghc-mod", ghcModPath);
validateExecutableIfNonEmpty("ghc-modi", ghcModiPath);
validateExecutableIfNonEmpty("hindent", hindentPath);
}
public void validateExecutable(String name, TextAccessor field) throws ConfigurationException {
if (new File(field.getText()).canExecute()) return;
throw new ConfigurationException("Not a valid '" + name + "' executable: '" + field.getText() + "'");
}
public void validateExecutableIfNonEmpty(String name, TextAccessor field) throws ConfigurationException {
if (field.getText().isEmpty()) return;
validateExecutable(name, field);
}
/**
* Triggered when the user pushes the cancel button.
*/
@Override
public void reset() {
restoreState();
}
@Override
public void disposeUIResources() {
}
/**
* Heuristically finds the version number. Current implementation is the
* identity function since cabal plays nice.
*/
@Nullable
private static String getVersion(String cmd, String versionFlag) {
return ExecUtil.readCommandLine(null, cmd, versionFlag).fold(
new AbstractFunction1<ExecUtil.ExecError, String>() {
@Override
public String apply(ExecUtil.ExecError e) {
NotificationUtil.displaySimpleNotification(
NotificationType.ERROR, null, "Haskell Tools", e.getMessage()
);
return null;
}
},
new AbstractFunction1<String, String>() {
@Override
public String apply(String s) {
return s;
}
}
);
}
/**
* Updates the version info fields for all files configured.
*/
private void updateVersionInfoFields() {
for (Property property : properties) {
if (property instanceof Versioned) {
((Versioned)property).updateVersion();
}
}
}
/**
* Persistent save of the current state.
*/
private void saveState() {
preSaveHook();
for (Property property : properties) {
property.saveState();
}
}
/**
* Updates tool settings before saving.
*/
private void preSaveHook() {
ghcModLegacyInteractivePreSaveHook();
}
private static Pattern GHC_MOD_VERSION_REGEX = Pattern.compile("(\\d+)\\.(\\d+)");
@Nullable
public static Pair<Integer, Integer> parseGhcModVersion(String version) {
if (version == null) return null;
Matcher m = GHC_MOD_VERSION_REGEX.matcher(version);
if (!m.find()) {
LOG.error("Could not find ghc-mod version number from string: " + version);
return null;
}
return Pair.create(Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)));
}
/**
* If we're using ghc-mod >= 5.4, ghc-modi will be configured as `ghc-mod legacy-interactive`
*/
private void ghcModLegacyInteractivePreSaveHook() {
// If ghc-mod is not configured or is not >= 5.4, we can't infer legacy-interactive.
if (ghcModPath.getText().isEmpty() || !isGhcMod5_4(ghcModPath.getText())) return;
// If ghc-modi is configured and it is not >= 5.4, leave it alone.
if (!ghcModiPath.getText().isEmpty() && !isGhcMod5_4(ghcModiPath.getText())) return;
// If all is good, configure ghc-modi as legacy-interactive.
ghcModiPath.setText(ghcModPath.getText());
// If the current ghc-modi flags contains the `legacy-interactive` command, do not add it back.
if (!ghcModiFlags.getText().contains("legacy-interactive")) {
ghcModiFlags.setText(ghcModiFlags.getText() + " legacy-interactive");
}
}
private boolean isGhcMod5_4(String exePath) {
String versionStr = getVersion(exePath, "version");
if (versionStr == null) {
LOG.warn("Could not retrieve ghc-mod version from " + exePath);
return false;
}
Pair<Integer, Integer> version = parseGhcModVersion(versionStr);
if (version == null) {
LOG.warn("Could not parse ghc-mod version from string: " + versionStr);
return false;
}
return version.first > 5 || (version.first == 5 && version.second >= 4);
}
/**
* Restore components to the initial state.
*/
private void restoreState() {
for (Property property : properties) {
property.restoreState();
}
}
}