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(); } } }