From cfe2ef1f81bfdbc19ba4ef8a1b0f18b3e876cf79 Mon Sep 17 00:00:00 2001 From: Johannes Spangenberg Date: Mon, 29 Apr 2024 21:22:37 +0000 Subject: [PATCH] Add LSP support [paid versions of IDEA only] (#68) --- CHANGELOG.md | 4 + build.gradle.kts | 11 +- gradle.properties | 6 +- .../idea/lsp/NixLspServerDescriptor.java | 34 +++ .../idea/lsp/NixLspServerSupportProvider.java | 26 +++ .../org/nixos/idea/lsp/NixLspSettings.java | 78 +++++++ .../idea/lsp/NixLspSettingsConfigurable.java | 99 +++++++++ .../idea/lsp/ui/CommandSuggestionsPopup.java | 201 ++++++++++++++++++ .../resources/META-INF/nix-idea-ultimate.xml | 17 ++ src/main/resources/META-INF/plugin.xml | 3 +- 10 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/nixos/idea/lsp/NixLspServerDescriptor.java create mode 100644 src/main/java/org/nixos/idea/lsp/NixLspServerSupportProvider.java create mode 100644 src/main/java/org/nixos/idea/lsp/NixLspSettings.java create mode 100644 src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java create mode 100644 src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java create mode 100644 src/main/resources/META-INF/nix-idea-ultimate.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3d2859..6cb5e9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Plugin logo for easier recognition +- Experimental Language Server support using IDEA's LSP API (#68) + [(Only works for paid versions of IDEA 😞)](https://blog.jetbrains.com/platform/2023/07/lsp-for-plugin-developers/#supported-ides) ### Changed @@ -17,6 +19,8 @@ ### Removed +- Support for IDEA 2023.1 + ### Fixed - Variables behind `inherit` keyword not correctly resolved during highlighting diff --git a/build.gradle.kts b/build.gradle.kts index eb319685..959c668a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML -import org.jetbrains.intellij.tasks.RunPluginVerifierTask +import org.jetbrains.intellij.tasks.RunPluginVerifierTask.FailureLevel +import java.util.EnumSet plugins { id("java") @@ -167,7 +168,13 @@ tasks { } runPluginVerifier { - failureLevel = RunPluginVerifierTask.FailureLevel.ALL + failureLevel = EnumSet.complementOf( + EnumSet.of( + FailureLevel.DEPRECATED_API_USAGES, + FailureLevel.SCHEDULED_FOR_REMOVAL_API_USAGES, + FailureLevel.EXPERIMENTAL_API_USAGES, + ) + ) // Version 1.364 seems to be broken and always complains about supposedly missing 'plugin.xml': // https://youtrack.jetbrains.com/issue/MP-6388 verifierVersion = "1.307" diff --git a/gradle.properties b/gradle.properties index b4cee76d..8faf22c2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,11 +4,11 @@ pluginGroup = org.nixos.idea pluginName = NixIDEA pluginVersion = 0.4.0.13 -pluginSinceBuild = 231 +pluginSinceBuild = 232 pluginUntilBuild = 241.* -platformType = IC -platformVersion = 2023.1.6 +platformType = IU +platformVersion = 2023.2.6 # Gradle Configuration # -> https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties diff --git a/src/main/java/org/nixos/idea/lsp/NixLspServerDescriptor.java b/src/main/java/org/nixos/idea/lsp/NixLspServerDescriptor.java new file mode 100644 index 00000000..b73bd6f7 --- /dev/null +++ b/src/main/java/org/nixos/idea/lsp/NixLspServerDescriptor.java @@ -0,0 +1,34 @@ +package org.nixos.idea.lsp; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor; +import com.intellij.util.execution.ParametersListUtil; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.file.NixFileType; + +import java.util.List; + +@SuppressWarnings("UnstableApiUsage") +final class NixLspServerDescriptor extends ProjectWideLspServerDescriptor { + + private final String myCommand; + + NixLspServerDescriptor(@NotNull Project project, NixLspSettings settings) { + super(project, "Nix"); + myCommand = settings.getCommand(); + } + + @Override + public @NotNull GeneralCommandLine createCommandLine() throws ExecutionException { + List argv = ParametersListUtil.parse(myCommand, false, true); + return new GeneralCommandLine(argv); + } + + @Override + public boolean isSupportedFile(@NotNull VirtualFile virtualFile) { + return virtualFile.getFileType() == NixFileType.INSTANCE; + } +} diff --git a/src/main/java/org/nixos/idea/lsp/NixLspServerSupportProvider.java b/src/main/java/org/nixos/idea/lsp/NixLspServerSupportProvider.java new file mode 100644 index 00000000..62d17e67 --- /dev/null +++ b/src/main/java/org/nixos/idea/lsp/NixLspServerSupportProvider.java @@ -0,0 +1,26 @@ +package org.nixos.idea.lsp; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.platform.lsp.api.LspServerSupportProvider; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.file.NixFileType; + +@SuppressWarnings("UnstableApiUsage") +public final class NixLspServerSupportProvider implements LspServerSupportProvider { + @Override + public void fileOpened(@NotNull Project project, @NotNull VirtualFile virtualFile, @NotNull LspServerStarter lspServerStarter) { + if (virtualFile.getFileType() == NixFileType.INSTANCE) { + NixLspSettings settings = NixLspSettings.getInstance(); + if (settings.isEnabled()) { + lspServerStarter.ensureServerStarted(new NixLspServerDescriptor(project, settings)); + } + } + } + + // TODO: Uncomment with IDEA 2024.1 + //@Override + //public @NotNull LspServerWidgetItem createLspServerWidgetItem(@NotNull LspServer lspServer, @Nullable VirtualFile currentFile) { + // return new LspServerWidgetItem(lspServer, currentFile, NixIcons.FILE, NixLspSettingsConfigurable.class); + //} +} diff --git a/src/main/java/org/nixos/idea/lsp/NixLspSettings.java b/src/main/java/org/nixos/idea/lsp/NixLspSettings.java new file mode 100644 index 00000000..3eb03e74 --- /dev/null +++ b/src/main/java/org/nixos/idea/lsp/NixLspSettings.java @@ -0,0 +1,78 @@ +package org.nixos.idea.lsp; + +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 = "NixLspSettings", storages = @Storage(value = "nix-idea-tools.xml", 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 + + private static final int MAX_HISTORY_SIZE = 5; + + private @NotNull State myState = new State(); + + public static @NotNull NixLspSettings getInstance() { + return ApplicationManager.getApplication().getService(NixLspSettings.class); + } + + public boolean isEnabled() { + return myState.enabled; + } + + public void setEnabled(boolean enabled) { + myState.enabled = enabled; + } + + public @NotNull String getCommand() { + return myState.command; + } + + public void setCommand(@NotNull String command) { + myState.command = command; + addToHistory(command); + } + + public @NotNull Collection getCommandHistory() { + return Collections.unmodifiableCollection(myState.history); + } + + private void addToHistory(@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/lsp/NixLspSettingsConfigurable.java b/src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java new file mode 100644 index 00000000..a819bff5 --- /dev/null +++ b/src/main/java/org/nixos/idea/lsp/NixLspSettingsConfigurable.java @@ -0,0 +1,99 @@ +package org.nixos.idea.lsp; + +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.SearchableConfigurable; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.platform.lsp.api.LspServerManager; +import com.intellij.ui.RawCommandLineEditor; +import com.intellij.ui.TitledSeparator; +import com.intellij.ui.components.JBCheckBox; +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.JComponent; +import javax.swing.JPanel; + +public class NixLspSettingsConfigurable implements SearchableConfigurable, Configurable.Beta { + + private @Nullable JBCheckBox myEnabled; + private @Nullable RawCommandLineEditor myCommand; + + @Override + public @NotNull @NonNls String getId() { + return "org.nixos.idea.lsp.NixLspSettings"; + } + + @Override + public @NlsContexts.ConfigurableName String getDisplayName() { + return "Language Server (LSP)"; + } + + @Override + public @Nullable JComponent createComponent() { + myEnabled = new JBCheckBox("Enable language server"); + myEnabled.addChangeListener(e -> updateUiState()); + + myCommand = new RawCommandLineEditor(); + 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(); + + return FormBuilder.createFormBuilder() + .addComponent(myEnabled) + .addComponent(new TitledSeparator("Language Server Configuration")) + .addLabeledComponent("Command: ", myCommand) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } + + @Override + public void reset() { + assert myEnabled != null; + assert myCommand != null; + + NixLspSettings settings = NixLspSettings.getInstance(); + myEnabled.setSelected(settings.isEnabled()); + myCommand.setText(settings.getCommand()); + + updateUiState(); + } + + @SuppressWarnings("UnstableApiUsage") + @Override + public void apply() throws ConfigurationException { + assert myEnabled != null; + assert myCommand != null; + + NixLspSettings settings = NixLspSettings.getInstance(); + settings.setEnabled(myEnabled.isSelected()); + settings.setCommand(myCommand.getText()); + + for (Project project : ProjectManager.getInstance().getOpenProjects()) { + LspServerManager.getInstance(project).stopAndRestartIfNeeded(NixLspServerSupportProvider.class); + } + } + + @Override + public boolean isModified() { + assert myEnabled != null; + assert myCommand != null; + + NixLspSettings settings = NixLspSettings.getInstance(); + return Configurable.isCheckboxModified(myEnabled, settings.isEnabled()) || + Configurable.isFieldModified(myCommand.getTextField(), settings.getCommand()); + } + + private void updateUiState() { + assert myEnabled != null; + assert myCommand != null; + + myCommand.setEnabled(myEnabled.isSelected()); + } +} diff --git a/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java b/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java new file mode 100644 index 00000000..af935163 --- /dev/null +++ b/src/main/java/org/nixos/idea/lsp/ui/CommandSuggestionsPopup.java @@ -0,0 +1,201 @@ +package org.nixos.idea.lsp.ui; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.CustomShortcutSet; +import com.intellij.openapi.actionSystem.IdeActions; +import com.intellij.openapi.keymap.KeymapUtil; +import com.intellij.openapi.project.DumbAwareAction; +import com.intellij.openapi.ui.UiUtils; +import com.intellij.openapi.ui.popup.JBPopupListener; +import com.intellij.openapi.ui.popup.LightweightWindowEvent; +import com.intellij.openapi.ui.popup.ListPopup; +import com.intellij.openapi.ui.popup.ListPopupStepEx; +import com.intellij.openapi.ui.popup.PopupStep; +import com.intellij.openapi.ui.popup.util.BaseListPopupStep; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.ui.DocumentAdapter; +import com.intellij.ui.RawCommandLineEditor; +import com.intellij.ui.components.fields.ExpandableTextField; +import com.intellij.ui.components.fields.ExtendableTextComponent; +import com.intellij.ui.popup.list.ListPopupImpl; +import com.intellij.util.ui.StatusText; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.Icon; +import javax.swing.KeyStroke; +import javax.swing.event.CaretEvent; +import javax.swing.event.CaretListener; +import javax.swing.event.DocumentEvent; +import java.awt.event.FocusEvent; +import java.awt.event.FocusListener; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.util.Collection; +import java.util.List; +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") + ); + + private final @NotNull ExpandableTextField myEditor; + private final @NotNull Collection myHistory; + private @Nullable ListPopup myPopup; + + public CommandSuggestionsPopup(@NotNull RawCommandLineEditor commandLineEditor, @NotNull Collection history) { + myEditor = commandLineEditor.getEditorField(); + myHistory = history; + } + + public void install() { + MyEventListener listener = new MyEventListener(); + myEditor.addFocusListener(listener); + myEditor.addCaretListener(listener); + myEditor.getDocument().addDocumentListener(listener); + + KeyStroke keyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.SHIFT_DOWN_MASK); + myEditor.addExtension(ExtendableTextComponent.Extension.create( + AllIcons.General.InlineVariables, + AllIcons.General.InlineVariablesHover, + "Show suggestions... (" + KeymapUtil.getKeystrokeText(keyStroke) + ")", + this::show)); + DumbAwareAction.create(__ -> show()).registerCustomShortcutSet(new CustomShortcutSet(keyStroke), myEditor); + } + + public void show() { + if (myPopup == null) { + myPopup = new MyListPopup(); + myPopup.showUnderneathOf(myEditor); + } + } + + public void hide() { + if (myPopup != null) { + myPopup.cancel(); + assert myPopup == null; + } + } + + private final class MyEventListener extends DocumentAdapter implements CaretListener, FocusListener { + + @Override + public void focusGained(FocusEvent e) { + if (myEditor.getText().isEmpty()) { + show(); + } + } + + @Override + public void focusLost(FocusEvent e) { + hide(); + } + + @Override + protected void textChanged(@NotNull DocumentEvent e) { + hide(); + } + + @Override + public void caretUpdate(CaretEvent e) { + hide(); + } + } + + private final class MyListPopup extends ListPopupImpl implements JBPopupListener { + private MyListPopup() { + super(null, new MyListPopupStep()); + // Disable focus in popup, so that the text field stays in focus. + setRequestFocus(false); + // Prevent the popup from overriding the paste-action. + // Preventing users from pasting while the popup is open would be annoying, + // as the popup may open automatically when you focus the text field. + UiUtils.removeKeyboardAction(getList(), UiUtils.getKeyStrokes(IdeActions.ACTION_PASTE)); + // Register listener, which informs the outer class when the popup is closed + addListener(this); + } + + @Override + 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 -> {} + default -> super.process(aEvent); + } + } + + @Override + public void onClosed(@NotNull LightweightWindowEvent event) { + myPopup = null; + } + } + + private record Suggestion( + @NotNull Icon icon, + @NotNull String primaryText, + @Nullable String secondaryText, + @NotNull String command + ) { + static @NotNull Suggestion builtin(@NotNull String name, @NotNull String command) { + return new Suggestion(AllIcons.Actions.Lightning, name, command, command); + } + + static @NotNull Suggestion history(@NotNull String command) { + return new Suggestion(AllIcons.Vcs.History, command, null, command); + } + + @Override + public String toString() { + // This method is called by IntelliJ when the user presses Ctrl+C + return command(); + } + } + + private final class MyListPopupStep extends BaseListPopupStep implements ListPopupStepEx { + + public MyListPopupStep() { + super(null, Stream.concat( + BUILDIN_SUGGESTIONS.stream(), + myHistory.stream().map(Suggestion::history) + ).toList()); + } + + @Override + public Icon getIconFor(Suggestion value) { + return value.icon(); + } + + @Override + public @NotNull String getTextFor(Suggestion value) { + return value.primaryText(); + } + + @Override + public @Nls @Nullable String getValueFor(Suggestion suggestion) { + return suggestion.secondaryText(); + } + + @Override + public @NlsContexts.Tooltip @Nullable String getTooltipTextFor(Suggestion value) { + return null; + } + + @Override + public void setEmptyText(@NotNull StatusText emptyText) { + } + + @Override + public @Nullable PopupStep onChosen(Suggestion selectedValue, boolean finalChoice) { + myEditor.setText(selectedValue.command()); + return FINAL_CHOICE; + } + } +} diff --git a/src/main/resources/META-INF/nix-idea-ultimate.xml b/src/main/resources/META-INF/nix-idea-ultimate.xml new file mode 100644 index 00000000..297586f4 --- /dev/null +++ b/src/main/resources/META-INF/nix-idea-ultimate.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 0996ae42..e226a31b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -5,6 +5,7 @@ NixOS com.intellij.modules.lang + com.intellij.modules.ultimate @@ -38,7 +39,7 @@ implementationClass="org.nixos.idea.lang.NixCommenter"/>