diff --git a/.gitignore b/.gitignore index eb014e03..c715be0c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ hs_err_pid* *.iml **/.gradle build + +src/gen diff --git a/CHANGELOG.md b/CHANGELOG.md index ddfe20a7..c3cf5fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Support for code formatting via external commands ([#80](https://github.com/NixOS/nix-idea/pull/80)) + ### Changed ### Deprecated diff --git a/src/main/java/org/nixos/idea/format/NixExternalFormatter.java b/src/main/java/org/nixos/idea/format/NixExternalFormatter.java new file mode 100644 index 00000000..0472f0d1 --- /dev/null +++ b/src/main/java/org/nixos/idea/format/NixExternalFormatter.java @@ -0,0 +1,112 @@ +package org.nixos.idea.format; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.CapturingProcessAdapter; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.execution.process.ProcessEvent; +import com.intellij.formatting.service.AsyncDocumentFormattingService; +import com.intellij.formatting.service.AsyncFormattingRequest; +import com.intellij.openapi.util.NlsSafe; +import com.intellij.psi.PsiFile; +import com.intellij.util.execution.ParametersListUtil; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.file.NixFile; +import org.nixos.idea.lang.NixLanguage; +import org.nixos.idea.settings.NixExternalFormatterSettings; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +public final class NixExternalFormatter extends AsyncDocumentFormattingService { + + @Override + protected @NotNull String getNotificationGroupId() { + return NixLanguage.NOTIFICATION_GROUP_ID; + } + + @Override + protected @NotNull @NlsSafe String getName() { + return "NixIDEA"; + } + + @Override + public @NotNull Set getFeatures() { + return EnumSet.noneOf(Feature.class); + } + + @Override + public boolean canFormat(@NotNull PsiFile psiFile) { + return psiFile instanceof NixFile; + } + + + @Override + protected @Nullable FormattingTask createFormattingTask(@NotNull AsyncFormattingRequest request) { + NixExternalFormatterSettings nixSettings = NixExternalFormatterSettings.getInstance(); + if (!nixSettings.isFormatEnabled()) { + return null; + } + + var ioFile = request.getIOFile(); + if (ioFile == null) return null; + + @NonNls + var command = nixSettings.getFormatCommand(); + List argv = ParametersListUtil.parse(command, false, true); + + var commandLine = new GeneralCommandLine(argv); + + try { + var handler = new OSProcessHandler(commandLine.withCharset(StandardCharsets.UTF_8)); + OutputStream processInput = handler.getProcessInput(); + return new FormattingTask() { + @Override + public void run() { + handler.addProcessListener(new CapturingProcessAdapter() { + + @Override + public void processTerminated(@NotNull ProcessEvent event) { + int exitCode = event.getExitCode(); + if (exitCode == 0) { + request.onTextReady(getOutput().getStdout()); + } else { + request.onError("NixIDEA", getOutput().getStderr()); + } + } + }); + handler.startNotify(); + try { + Files.copy(ioFile.toPath(), processInput); + processInput.flush(); + processInput.close(); + } catch (IOException e) { + handler.destroyProcess(); + request.onError("NixIDEA", e.getMessage()); + } + } + + @Override + public boolean cancel() { + handler.destroyProcess(); + return true; + } + + @Override + public boolean isRunUnderProgress() { + return true; + } + }; + } catch (ExecutionException e) { + request.onError("NixIDEA", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/org/nixos/idea/lang/NixLanguage.java b/src/main/java/org/nixos/idea/lang/NixLanguage.java index ff75d563..a7b4c195 100644 --- a/src/main/java/org/nixos/idea/lang/NixLanguage.java +++ b/src/main/java/org/nixos/idea/lang/NixLanguage.java @@ -4,6 +4,7 @@ public class NixLanguage extends Language { public static final NixLanguage INSTANCE = new NixLanguage(); + public static final String NOTIFICATION_GROUP_ID = "NixIDEA"; private NixLanguage() { super("Nix"); diff --git a/src/main/java/org/nixos/idea/lsp/NixLspSettings.java b/src/main/java/org/nixos/idea/lsp/NixLspSettings.java index 3eb03e74..a92d27a6 100644 --- a/src/main/java/org/nixos/idea/lsp/NixLspSettings.java +++ b/src/main/java/org/nixos/idea/lsp/NixLspSettings.java @@ -6,17 +6,16 @@ import com.intellij.openapi.components.State; import com.intellij.openapi.components.Storage; import org.jetbrains.annotations.NotNull; +import org.nixos.idea.settings.NixStoragePaths; import java.util.ArrayDeque; import java.util.Collection; import java.util.Collections; import java.util.Deque; -@State(name = "NixLspSettings", storages = @Storage(value = "nix-idea-tools.xml", roamingType = RoamingType.DISABLED)) +@State(name = "NixLspSettings", storages = @Storage(value = NixStoragePaths.TOOLS, roamingType = RoamingType.DISABLED)) public final class NixLspSettings implements PersistentStateComponent { - // TODO: Use RoamingType.LOCAL with 2024.1 - // Documentation: // https://plugins.jetbrains.com/docs/intellij/persisting-state-of-components.html diff --git a/src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java b/src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java index bdfcd081..3fe41802 100644 --- a/src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java +++ b/src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java @@ -18,8 +18,15 @@ import javax.swing.JComponent; import javax.swing.JPanel; +import java.util.List; public class NixLspSettingsConfigurable implements SearchableConfigurable, Configurable.Beta { + private static final List BUILTIN_SUGGESTIONS = List.of( + CommandSuggestionsPopup.Suggestion.builtin("Use nil from nixpkgs", + "nix --extra-experimental-features \"nix-command flakes\" run nixpkgs#nil"), + CommandSuggestionsPopup.Suggestion.builtin("Use nixd from nixpkgs", + "nix --extra-experimental-features \"nix-command flakes\" run nixpkgs#nixd") + ); private @Nullable JBCheckBox myEnabled; private @Nullable RawCommandLineEditor myCommand; @@ -43,7 +50,7 @@ public class NixLspSettingsConfigurable implements SearchableConfigurable, Confi myCommand.getEditorField().getEmptyText().setText("Command to start Language Server"); myCommand.getEditorField().getAccessibleContext().setAccessibleName("Command to start Language Server"); myCommand.getEditorField().setMargin(myEnabled.getMargin()); - new CommandSuggestionsPopup(myCommand, NixLspSettings.getInstance().getCommandHistory()).install(); + new CommandSuggestionsPopup(myCommand, NixLspSettings.getInstance().getCommandHistory(), BUILTIN_SUGGESTIONS).install(); return FormBuilder.createFormBuilder() .addComponent(myEnabled) diff --git a/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java b/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java index af935163..f73c64c7 100644 --- a/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java +++ b/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java @@ -37,20 +37,19 @@ import java.util.stream.Stream; public final class CommandSuggestionsPopup { - // Implementation partially inspired by TextCompletionField - private static final List BUILDIN_SUGGESTIONS = List.of( - Suggestion.builtin("Use nil from nixpkgs", - "nix --extra-experimental-features \"nix-command flakes\" run nixpkgs#nil"), - Suggestion.builtin("Use nixd from nixpkgs", - "nix --extra-experimental-features \"nix-command flakes\" run nixpkgs#nixd") - ); + // Implementation partially inspired by TextCompletionField private final @NotNull ExpandableTextField myEditor; private final @NotNull Collection myHistory; private @Nullable ListPopup myPopup; + private final @NotNull List mySuggestions; - public CommandSuggestionsPopup(@NotNull RawCommandLineEditor commandLineEditor, @NotNull Collection history) { + public CommandSuggestionsPopup(@NotNull RawCommandLineEditor commandLineEditor, + @NotNull Collection history, + @NotNull List suggestions + ) { + mySuggestions = suggestions; myEditor = commandLineEditor.getEditorField(); myHistory = history; } @@ -127,7 +126,8 @@ protected void process(KeyEvent aEvent) { switch (aEvent.getKeyCode()) { // Do no handle left and right key, // as it would prevent their usage in the text field while the popup is open. - case KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT -> {} + case KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT -> { + } default -> super.process(aEvent); } } @@ -138,13 +138,13 @@ public void onClosed(@NotNull LightweightWindowEvent event) { } } - private record Suggestion( + public record Suggestion( @NotNull Icon icon, @NotNull String primaryText, @Nullable String secondaryText, @NotNull String command ) { - static @NotNull Suggestion builtin(@NotNull String name, @NotNull String command) { + public static @NotNull Suggestion builtin(@NotNull String name, @NotNull String command) { return new Suggestion(AllIcons.Actions.Lightning, name, command, command); } @@ -163,7 +163,7 @@ private final class MyListPopupStep extends BaseListPopupStep implem public MyListPopupStep() { super(null, Stream.concat( - BUILDIN_SUGGESTIONS.stream(), + mySuggestions.stream(), myHistory.stream().map(Suggestion::history) ).toList()); } diff --git a/src/main/java/org/nixos/idea/settings/NixExternalFormatterSettings.java b/src/main/java/org/nixos/idea/settings/NixExternalFormatterSettings.java new file mode 100644 index 00000000..9c10d6c0 --- /dev/null +++ b/src/main/java/org/nixos/idea/settings/NixExternalFormatterSettings.java @@ -0,0 +1,76 @@ +package org.nixos.idea.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.RoamingType; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; + +@State(name = "NixExternalFormatterSettings", storages = @Storage(value = NixStoragePaths.TOOLS, roamingType = RoamingType.DISABLED)) +public final class NixExternalFormatterSettings implements PersistentStateComponent { + + // Documentation: + // https://plugins.jetbrains.com/docs/intellij/persisting-state-of-components.html + + private static final int MAX_HISTORY_SIZE = 5; + + private @NotNull State myState = new State(); + + public static @NotNull NixExternalFormatterSettings getInstance() { + return ApplicationManager.getApplication().getService(NixExternalFormatterSettings.class); + } + + public boolean isFormatEnabled() { + return myState.enabled; + } + + public void setFormatEnabled(boolean enabled) { + myState.enabled = enabled; + } + + public @NotNull String getFormatCommand() { + return myState.command; + } + + public void setFormatCommand(@NotNull String command) { + myState.command = command; + addFormatCommandToHistory(command); + } + + public @NotNull Collection getCommandHistory() { + return Collections.unmodifiableCollection(myState.history); + } + + private void addFormatCommandToHistory(@NotNull String command) { + Deque history = myState.history; + history.remove(command); + history.addFirst(command); + while (history.size() > MAX_HISTORY_SIZE) { + history.removeLast(); + } + } + + @SuppressWarnings("ClassEscapesDefinedScope") + @Override + public void loadState(@NotNull State state) { + myState = state; + } + + @SuppressWarnings("ClassEscapesDefinedScope") + @Override + public @NotNull State getState() { + return myState; + } + + static final class State { + public boolean enabled = false; + public @NotNull String command = ""; + public Deque history = new ArrayDeque<>(); + } +} diff --git a/src/main/java/org/nixos/idea/settings/NixLangSettingsConfigurable.java b/src/main/java/org/nixos/idea/settings/NixLangSettingsConfigurable.java new file mode 100644 index 00000000..c1b24c22 --- /dev/null +++ b/src/main/java/org/nixos/idea/settings/NixLangSettingsConfigurable.java @@ -0,0 +1,107 @@ +package org.nixos.idea.settings; + +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.SearchableConfigurable; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.ui.RawCommandLineEditor; +import com.intellij.ui.TitledSeparator; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.FormBuilder; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lsp.ui.CommandSuggestionsPopup; + +import javax.swing.*; +import java.util.List; + +public class NixLangSettingsConfigurable implements SearchableConfigurable, Configurable.Beta { + private static final List BUILTIN_SUGGESTIONS = List.of( + CommandSuggestionsPopup.Suggestion.builtin("Use nixpkgs-fmt from nixpkgs", + "nix --extra-experimental-features \"nix-command flakes\" run nixpkgs#nixpkgs-fmt") + ); + + private @Nullable JBCheckBox myEnabled; + private @Nullable RawCommandLineEditor myCommand; + private @Nullable JBLabel myTextArea; + + @Override + public @NotNull @NonNls String getId() { + return "org.nixos.idea.settings.NixLangSettingsConfigurable"; + } + + @Override + public @NlsContexts.ConfigurableName String getDisplayName() { + return "Nix"; + } + + @Override + public @Nullable JComponent createComponent() { + myEnabled = new JBCheckBox("Enable external formatter"); + myEnabled.addChangeListener(e -> updateUiState()); + + myCommand = new RawCommandLineEditor(); + myCommand.getEditorField().getEmptyText().setText("Command to execute for formatting"); + myCommand.getEditorField().getAccessibleContext().setAccessibleName("Command to execute for formatting"); + + myTextArea = new JBLabel(); + + myTextArea.setText("Format Nix files via an external formatter. Source of focused file will be passed as standard input."); + new CommandSuggestionsPopup( + myCommand, + NixExternalFormatterSettings.getInstance().getCommandHistory(), + BUILTIN_SUGGESTIONS + ).install(); + + + return FormBuilder.createFormBuilder() + .addComponent(new TitledSeparator("External Formatter Configuration")) + .addComponent(myTextArea) + .addComponent(myEnabled) + .addLabeledComponent("Command: ", myCommand) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } + + @Override + public void reset() { + assert myEnabled != null; + assert myCommand != null; + + NixExternalFormatterSettings settings = NixExternalFormatterSettings.getInstance(); + myEnabled.setSelected(settings.isFormatEnabled()); + myCommand.setText(settings.getFormatCommand()); + + updateUiState(); + } + + @SuppressWarnings("UnstableApiUsage") + @Override + public void apply() throws ConfigurationException { + assert myEnabled != null; + assert myCommand != null; + + var formatterSettings = NixExternalFormatterSettings.getInstance(); + formatterSettings.setFormatEnabled(myEnabled.isSelected()); + formatterSettings.setFormatCommand(myCommand.getText()); + } + + @Override + public boolean isModified() { + assert myEnabled != null; + assert myCommand != null; + + var settings = NixExternalFormatterSettings.getInstance(); + return Configurable.isCheckboxModified(myEnabled, settings.isFormatEnabled()) || + Configurable.isFieldModified(myCommand.getTextField(), settings.getFormatCommand()); + } + + private void updateUiState() { + assert myEnabled != null; + assert myCommand != null; + + myCommand.setEnabled(myEnabled.isSelected()); + } +} diff --git a/src/main/java/org/nixos/idea/settings/NixStoragePaths.java b/src/main/java/org/nixos/idea/settings/NixStoragePaths.java new file mode 100644 index 00000000..7d0cbe3b --- /dev/null +++ b/src/main/java/org/nixos/idea/settings/NixStoragePaths.java @@ -0,0 +1,21 @@ +package org.nixos.idea.settings; + +import com.intellij.openapi.components.RoamingType; +import com.intellij.openapi.components.Storage; + +/** + * Constants to be used for {@link Storage#value()}. + */ +public final class NixStoragePaths { + + /** + * Location and configuration of external tools. + * The settings in the file are considered system dependent. + * This constant must be used with {@link RoamingType#DISABLED}. + * TODO: Use RoamingType.LOCAL with 2024.1 + */ + public static final String TOOLS = "nix-idea-tools.xml"; + + private NixStoragePaths() {} // Cannot be instantiated + +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index b247f2c9..159b1e7f 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -15,25 +15,25 @@ implementationClass="org.nixos.idea.file.NixFileType" fieldName="INSTANCE" language="Nix" - extensions="nix" /> + extensions="nix"/> + implementationClass="org.nixos.idea.lang.NixParserDefinition"/> + implementationClass="org.nixos.idea.lang.highlighter.NixSyntaxHighlighterFactory"/> - - + + + implementationClass="org.nixos.idea.lang.NixBraceMatcher"/> + instance="org.nixos.idea.settings.NixIDEASettings"/> + implementation="org.nixos.idea.settings.NixColorSettingsPage"/> + + + + + + +