diff --git a/build.gradle.kts b/build.gradle.kts index cb0c02da7..bf6d2ae1f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { "com.jetbrains.php", "com.jetbrains.twig", "com.jetbrains.php.dql", + "org.jetbrains.plugins.terminal", "de.espend.idea.php.annotation", "de.espend.idea.php.toolbox" ) @@ -61,11 +62,13 @@ intellijPlatform { name = properties("pluginName") } + buildSearchableOptions = false + pluginVerification { ides { create(type, version) { useInstaller = false - useCache = true + useCache = false } } } diff --git a/gradle.properties b/gradle.properties index 41efc2f46..8b2a2a2b6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ pluginUntilBuild = 299.* # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties platformType = IU -platformVersion = 2025.2.5 +platformVersion = 2025.3 # Java language level used to compile sources and to generate the files for - Java 11 is required since 2020.3 javaVersion = 21 @@ -29,4 +29,5 @@ gradleVersion = 9.2.1 kotlin.stdlib.default.dependency = false -org.gradle.configuration-cache=true \ No newline at end of file +org.gradle.configuration-cache=false +kotlin.daemon.jvmargs=-Xmx2g \ No newline at end of file diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/command/CommandNameTerminalCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/command/CommandNameTerminalCompletionContributor.java new file mode 100644 index 000000000..27502e6ae --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/command/CommandNameTerminalCompletionContributor.java @@ -0,0 +1,152 @@ +package fr.adrienbrault.idea.symfony2plugin.completion.command; + +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; +import fr.adrienbrault.idea.symfony2plugin.util.SymfonyCommandUtil; +import fr.adrienbrault.idea.symfony2plugin.util.dict.SymfonyCommand; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.plugins.terminal.block.util.TerminalDataContextUtils; +import org.jetbrains.plugins.terminal.view.TerminalOffset; +import org.jetbrains.plugins.terminal.view.TerminalOutputModel; +import org.jetbrains.plugins.terminal.view.shellIntegration.TerminalBlockBase; +import org.jetbrains.plugins.terminal.view.shellIntegration.TerminalBlocksModel; +import org.jetbrains.plugins.terminal.view.shellIntegration.TerminalCommandBlock; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provides code completion for Symfony console command names in the integrated terminal. + * + *

This completion contributor activates when typing Symfony console commands in the terminal, + * detecting patterns like {@code bin/console } or {@code console } and offering + * autocompletion suggestions for available Symfony commands.

+ * + *

Examples:

+ *
+ * # Terminal input                    → Completion result
+ * $ bin/console                → Shows all available commands
+ * $ console cac                → Filters to cache:* commands
+ * $ bin/console debug:con      → Suggests debug:container, debug:config, etc.
+ * 
+ */ +public class CommandNameTerminalCompletionContributor extends CompletionContributor implements DumbAware { + + @Override + public void fillCompletionVariants(@NotNull CompletionParameters parameters, @NotNull CompletionResultSet result) { + // Only handle basic completion + if (parameters.getCompletionType() != CompletionType.BASIC) { + return; + } + + Editor editor = parameters.getEditor(); + Project project = editor.getProject(); + if (project == null) { + return; + } + + if (!TerminalDataContextUtils.INSTANCE.isReworkedTerminalEditor(editor)) { + return; + } + + // Check if this is a terminal editor + TerminalOutputModel outputModel = editor.getUserData(TerminalOutputModel.Companion.getKEY()); + if (outputModel == null) { + return; + } + + TerminalBlocksModel blocksModel = editor.getUserData(TerminalBlocksModel.Companion.getKEY()); + if (blocksModel == null) { + return; + } + + // Get the active command block + TerminalBlockBase activeBlock = blocksModel.getActiveBlock(); + if (!(activeBlock instanceof TerminalCommandBlock commandBlock)) { + return; + } + + TerminalOffset commandStartOffset = commandBlock.getCommandStartOffset(); + if (commandStartOffset == null) { + return; + } + + // Get the command text from the start of the command to the caret + int caretOffset = editor.getCaretModel().getOffset(); + String commandText = outputModel.getText( + commandStartOffset, + outputModel.getStartOffset().plus(caretOffset) + ).toString(); + + // Check if the command starts with "bin/console" or "console" + if (!SymfonyCommandUtil.isSymfonyConsoleCommand(commandText)) { + return; + } + + // Extract the prefix for completion + String prefix = extractCompletionPrefix(commandText); + + // If prefix is null, we're not in a valid position for command name completion + // (e.g., we're typing arguments/options after the command name) + if (prefix == null) { + return; + } + + // Update result set with a custom prefix matcher + CompletionResultSet customResult = result.withPrefixMatcher(new PlainPrefixMatcher(prefix, true)); + + customResult.addAllElements(getSymfonyCommandSuggestions(editor.getProject())); + } + + private String extractCompletionPrefix(String commandText) { + String trimmed = commandText.trim(); + + // Remove "bin/console " or "console " prefix + if (trimmed.startsWith("bin/console ")) { + trimmed = trimmed.substring("bin/console ".length()); + } else if (trimmed.startsWith("console ")) { + trimmed = trimmed.substring("console ".length()); + } else { + return null; + } + + // For now, we only support command name completion (not arguments) + // So we take everything until the first space + int spaceIndex = trimmed.indexOf(' '); + if (spaceIndex > 0) { + // Already typed a complete command with arguments, no completion + return null; + } + + return trimmed; + } + + private List getSymfonyCommandSuggestions(@NotNull Project project) { + List suggestions = new ArrayList<>(); + + for (SymfonyCommand command : SymfonyCommandUtil.getCommands(project)) { + String className = extractClassName(command.getFqn()); + suggestions.add(LookupElementBuilder.create(command.getName()) + .withTypeText(className, true) + .withIcon(Symfony2Icons.SYMFONY) + ); + } + + return suggestions; + } + + private String extractClassName(@NotNull String fqn) { + int lastBackslash = fqn.lastIndexOf('\\'); + + if (lastBackslash >= 0 && lastBackslash < fqn.length() - 1) { + return fqn.substring(lastBackslash + 1); + } + + return fqn; + } +} \ No newline at end of file diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/command/CommandOptionTerminalCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/command/CommandOptionTerminalCompletionContributor.java new file mode 100644 index 000000000..3ff062529 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/command/CommandOptionTerminalCompletionContributor.java @@ -0,0 +1,320 @@ +package fr.adrienbrault.idea.symfony2plugin.completion.command; + +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.jetbrains.php.PhpIndex; +import com.jetbrains.php.lang.psi.elements.PhpClass; +import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; +import fr.adrienbrault.idea.symfony2plugin.util.SymfonyCommandUtil; +import fr.adrienbrault.idea.symfony2plugin.util.dict.SymfonyCommand; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.plugins.terminal.block.util.TerminalDataContextUtils; +import org.jetbrains.plugins.terminal.view.TerminalOffset; +import org.jetbrains.plugins.terminal.view.TerminalOutputModel; +import org.jetbrains.plugins.terminal.view.shellIntegration.TerminalBlockBase; +import org.jetbrains.plugins.terminal.view.shellIntegration.TerminalBlocksModel; +import org.jetbrains.plugins.terminal.view.shellIntegration.TerminalCommandBlock; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Provides code completion for Symfony console command options in the integrated terminal. + * + *

This completion contributor activates when typing Symfony console command options, + * detecting patterns like {@code bin/console app:greet Fabien -} or + * {@code bin/console app:greet Fabien --} and offering autocompletion suggestions + * for available command options.

+ * + *

Examples:

+ *
+ * # Terminal input                              → Completion result
+ * $ bin/console app:greet Fabien -       → Shows all available options with shortcuts
+ * $ bin/console app:greet Fabien --      → Shows all available options (long form)
+ * $ bin/console app:greet Fabien         → Shows all available options with dashes
+ * 
+ */ +public class CommandOptionTerminalCompletionContributor extends CompletionContributor implements DumbAware { + + @Override + public void fillCompletionVariants(@NotNull CompletionParameters parameters, @NotNull CompletionResultSet result) { + // Only handle basic completion + if (parameters.getCompletionType() != CompletionType.BASIC) { + return; + } + + Editor editor = parameters.getEditor(); + Project project = editor.getProject(); + if (project == null) { + return; + } + + if (!TerminalDataContextUtils.INSTANCE.isReworkedTerminalEditor(editor)) { + return; + } + + // Check if this is a terminal editor + TerminalOutputModel outputModel = editor.getUserData(TerminalOutputModel.Companion.getKEY()); + if (outputModel == null) { + return; + } + + TerminalBlocksModel blocksModel = editor.getUserData(TerminalBlocksModel.Companion.getKEY()); + if (blocksModel == null) { + return; + } + + // Get the active command block + TerminalBlockBase activeBlock = blocksModel.getActiveBlock(); + if (!(activeBlock instanceof TerminalCommandBlock commandBlock)) { + return; + } + + TerminalOffset commandStartOffset = commandBlock.getCommandStartOffset(); + if (commandStartOffset == null) { + return; + } + + // Get the command text from the start of the command to the caret + int caretOffset = editor.getCaretModel().getOffset(); + String commandText = outputModel.getText( + commandStartOffset, + outputModel.getStartOffset().plus(caretOffset) + ).toString(); + + // Check if the command starts with "bin/console" or "console" + if (!SymfonyCommandUtil.isSymfonyConsoleCommand(commandText)) { + return; + } + + // Extract the command name and check if we're in the option completion context + String commandName = extractCommandName(commandText); + if (commandName == null) { + return; + } + + // Extract the prefix for completion (handles -, --, or no dash) + OptionCompletionContext context = extractOptionCompletionContext(commandText, commandName); + if (context == null) { + return; + } + + // Find the command by name + SymfonyCommand symfonyCommand = findCommandByName(project, commandName); + if (symfonyCommand == null) { + return; + } + + // Get the PhpClass for the command + Collection phpClasses = PhpIndex.getInstance(project).getAnyByFQN(symfonyCommand.getFqn()); + if (phpClasses.isEmpty()) { + return; + } + + PhpClass phpClass = phpClasses.iterator().next(); + + // Get command options + Map options = SymfonyCommandUtil.getCommandOptions(phpClass); + if (options.isEmpty()) { + return; + } + + // Update result set with a custom prefix matcher + CompletionResultSet customResult = result.withPrefixMatcher(new PlainPrefixMatcher(context.prefix, true)); + + // Add option suggestions based on context + customResult.addAllElements(getOptionSuggestions(options, context)); + } + + /** + * Extract the command name from the command text + * E.g., "bin/console app:greet Fabien -" -> "app:greet" + */ + @Nullable + private String extractCommandName(String commandText) { + String trimmed = commandText.trim(); + + // Remove "bin/console " or "console " prefix + if (trimmed.startsWith("bin/console ")) { + trimmed = trimmed.substring("bin/console ".length()); + } else if (trimmed.startsWith("console ")) { + trimmed = trimmed.substring("console ".length()); + } else { + return null; + } + + // Extract the command name (first word) + int spaceIndex = trimmed.indexOf(' '); + if (spaceIndex <= 0) { + return null; // No arguments/options yet + } + + return trimmed.substring(0, spaceIndex); + } + + /** + * Extract the option completion context (what comes after the command and arguments) + */ + @Nullable + private OptionCompletionContext extractOptionCompletionContext(String commandText, String commandName) { + String trimmed = commandText.trim(); + + // Remove "bin/console " or "console " prefix + if (trimmed.startsWith("bin/console ")) { + trimmed = trimmed.substring("bin/console ".length()); + } else if (trimmed.startsWith("console ")) { + trimmed = trimmed.substring("console ".length()); + } + + // Remove the command name + if (!trimmed.startsWith(commandName)) { + return null; + } + + // Remove command name and leading spaces, but keep trailing spaces + String afterCommand = trimmed.substring(commandName.length()).replaceFirst("^\\s+", ""); + + // Check if there's anything after the command + if (afterCommand.isEmpty()) { + return null; // No arguments or options yet + } + + // Find the last token to determine the completion context + // Support cases: + // 1. "app:greet Fabien -" -> after single dash + // 2. "app:greet Fabien --" -> after double dash + // 3. "app:greet Fabien " -> after space (show all options) + + if (afterCommand.endsWith("--")) { + // Case: "bin/console app:greet Fabien --" + // Show long-form options only + return new OptionCompletionContext("", OptionType.LONG_ONLY); + } else if (afterCommand.endsWith("-")) { + // Case: "bin/console app:greet Fabien -" + // Show both shortcuts and long-form options + return new OptionCompletionContext("", OptionType.BOTH); + } else { + // Case: "bin/console app:greet Fabien " + // Extract any partial option name + String[] tokens = afterCommand.split("\\s+"); + String lastToken = tokens[tokens.length - 1]; + + if (lastToken.startsWith("--")) { + // Partial long option: "bin/console app:greet --ver" + return new OptionCompletionContext(lastToken.substring(2), OptionType.LONG_ONLY); + } else if (lastToken.startsWith("-")) { + // Partial short option: "bin/console app:greet -v" + return new OptionCompletionContext(lastToken.substring(1), OptionType.BOTH); + } else { + // After a space, show all options with dashes + return new OptionCompletionContext("", OptionType.WITH_DASHES); + } + } + } + + /** + * Find a Symfony command by name + */ + @Nullable + private SymfonyCommand findCommandByName(Project project, String commandName) { + for (SymfonyCommand command : SymfonyCommandUtil.getCommands(project)) { + if (command.getName().equals(commandName)) { + return command; + } + } + return null; + } + + /** + * Get option suggestions based on the completion context + */ + private List getOptionSuggestions( + Map options, + OptionCompletionContext context + ) { + List suggestions = new ArrayList<>(); + + for (SymfonyCommandUtil.CommandOption option : options.values()) { + String description = option.description(); + if (description == null) { + description = ""; + } + + // Add long-form option (--option) + if (context.type == OptionType.LONG_ONLY || context.type == OptionType.BOTH) { + suggestions.add(LookupElementBuilder.create(option.name()) + .withPresentableText("--" + option.name()) + .withInsertHandler((insertContext, item) -> { + // Insert "--" + option name + insertContext.getDocument().insertString( + insertContext.getStartOffset(), + "--" + ); + }) + .withTypeText(description, true) + .withIcon(Symfony2Icons.SYMFONY) + ); + } + + // Add short-form option (-o) if available + if (!StringUtils.isBlank(option.shortcut())) { + if (context.type == OptionType.BOTH) { + suggestions.add(LookupElementBuilder.create(option.shortcut()) + .withPresentableText("-" + option.shortcut()) + .withInsertHandler((insertContext, item) -> { + // Insert "-" + shortcut + insertContext.getDocument().insertString( + insertContext.getStartOffset(), + "-" + ); + }) + .withTypeText(description + " (shortcut for --" + option.name() + ")", true) + .withIcon(Symfony2Icons.SYMFONY) + ); + } + } + + // Add with dashes prefix for the "after space" case + if (context.type == OptionType.WITH_DASHES) { + // Add long-form with -- + suggestions.add(LookupElementBuilder.create("--" + option.name()) + .withTypeText(description, true) + .withIcon(Symfony2Icons.SYMFONY) + ); + + // Add short-form with - if available + if (!StringUtils.isBlank(option.shortcut())) { + suggestions.add(LookupElementBuilder.create("-" + option.shortcut()) + .withTypeText(description + " (shortcut for --" + option.name() + ")", true) + .withIcon(Symfony2Icons.SYMFONY) + ); + } + } + } + + return suggestions; + } + + /** + * Context for option completion + */ + private record OptionCompletionContext(String prefix, OptionType type) { + } + + /** + * Type of option completion + */ + private enum OptionType { + LONG_ONLY, // After "--", show only long-form without prefix + BOTH, // After "-", show both short and long forms without prefix + WITH_DASHES // After space, show options with dashes + } +} diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/SymfonyCommandUtil.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/SymfonyCommandUtil.java index 8ce59edbe..8aaba1fe7 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/SymfonyCommandUtil.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/SymfonyCommandUtil.java @@ -66,6 +66,14 @@ public static Collection getCommands(@NotNull Project project) { .collect(Collectors.toList()); } + public static boolean isSymfonyConsoleCommand(@NotNull String commandText) { + String trimmed = commandText.trim(); + return trimmed.startsWith("bin/console ") || + trimmed.startsWith("console ") || + trimmed.equals("bin/console") || + trimmed.equals("console"); + } + /** * Collects all available option names and shortcuts from a command class. * Supports both traditional configure() methods with addOption() calls diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index dd51af956..d6fe00d31 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -227,7 +227,8 @@ - + + @@ -820,12 +821,12 @@ - com.jetbrains.twig com.jetbrains.php JavaScript com.intellij.modules.platform org.jetbrains.plugins.yaml + org.jetbrains.plugins.terminal de.espend.idea.php.annotation com.jetbrains.php.dql