diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index c7377d8efd..e3b0d3142e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -11,6 +11,7 @@ The full list of changes for this release can be found in https://github.com/dev == 2026.09.001 +* https://github.com/devonfw/IDEasy/issues/2293[#2293]: Rework spyder to have same features as IdeToolCommandlets Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1525[#1525]: Document known issue and workaround for lombok plugin in Eclipse diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java index ac4b466256..75bb02030b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java @@ -8,7 +8,7 @@ import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; +import com.devonfw.tools.ide.tool.plugin.PluginFeatures; /** * {@link Commandlet} to install a tool. @@ -49,7 +49,7 @@ protected void doRun() { ToolCommandlet commandlet = this.tool.getValue(); String plugin = this.plugin.getValue(); - if (commandlet instanceof PluginBasedCommandlet cmd) { + if (commandlet instanceof PluginFeatures cmd) { Step step = context.newStep("Install plugin: " + plugin); step.run(() -> cmd.installPlugin(cmd.getPlugin(plugin), step)); } else { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java index fc9bf3b4c6..9b8ede0799 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java @@ -7,7 +7,7 @@ import com.devonfw.tools.ide.property.PluginProperty; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; +import com.devonfw.tools.ide.tool.plugin.PluginFeatures; /** * {@link Commandlet} to install a tool. @@ -48,7 +48,7 @@ protected void doRun() { ToolCommandlet commandlet = this.tool.getValue(); String plugin = this.plugin.getValue(); - if (commandlet instanceof PluginBasedCommandlet cmd) { + if (commandlet instanceof PluginFeatures cmd) { cmd.uninstallPlugin(cmd.getPlugin(plugin)); } else { LOG.warn("Tool {} does not support plugins.", tool.getName()); diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java index b89a37151b..78eb235b83 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java @@ -19,7 +19,7 @@ import com.devonfw.tools.ide.property.RepositoryProperty; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; +import com.devonfw.tools.ide.tool.ide.IdeFeatures; /** * {@link Commandlet} to setup one or multiple GIT repositories for development. @@ -278,8 +278,8 @@ private void importRepository(RepositoryConfig repositoryConfig, Path repository String displayName = (ide == null || ide.isBlank()) ? "" : "'" + ide + "'"; step.error("Cannot import repository '{}'. Required IDE '{}' not found. Please check your repository's imports configuration.", repositoryId, displayName); - } else if (commandlet instanceof IdeToolCommandlet ideCommandlet) { - ideCommandlet.importRepository(repositoryPath); + } else if (commandlet instanceof IdeFeatures ideFeatures) { + ideFeatures.importRepository(repositoryPath); } else { step.error("Repository {} has import {} configured that is not an IDE!", repositoryId, ide); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java index 5108a85c0d..6ba157c1fc 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java @@ -53,6 +53,8 @@ public DirectoryMerger(IdeContext context) { this.extension2mergerMap.put("launch", xmlMerger); // Eclipse specific JsonMerger jsonMerger = new JsonMerger(context); this.extension2mergerMap.put("json", jsonMerger); + IniMerger iniMerger = new IniMerger(context); + this.extension2mergerMap.put("ini", iniMerger); TextMerger textMerger = new TextMerger(context); this.extension2mergerMap.put("name", textMerger); // intellij specific this.extension2mergerMap.put("editorconfig", textMerger); diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java new file mode 100644 index 0000000000..df59315a98 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java @@ -0,0 +1,189 @@ +package com.devonfw.tools.ide.merge; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.io.ini.IniFile; +import com.devonfw.tools.ide.io.ini.IniFileImpl; +import com.devonfw.tools.ide.io.ini.IniSection; + +/** + * Implementation of {@link FileMerger} for {@code .ini} files. + */ +public class IniMerger extends FileMerger { + + private static final Logger LOG = LoggerFactory.getLogger(IniMerger.class); + + /** + * The constructor. + * + * @param context the {@link #context}. + */ + public IniMerger(IdeContext context) { + super(context); + } + + @Override + protected void doMerge(Path setup, Path update, EnvironmentVariables resolver, Path workspace) { + + FileAccess fileAccess = this.context.getFileAccess(); + IniFile mergedIni = new IniFileImpl(); + boolean updateFileExists = Files.exists(update); + Path template = setup; + if (Files.exists(workspace)) { + if (!updateFileExists) { + LOG.trace("Nothing to do as update file does not exist: {}", update); + return; // nothing to do ... + } + fileAccess.readIniFile(workspace, mergedIni); + } else if (Files.exists(setup)) { + fileAccess.readIniFile(setup, mergedIni); + } + if (updateFileExists) { + IniFile updateIni = new IniFileImpl(); + fileAccess.readIniFile(update, updateIni); + mergeIniInto(updateIni, mergedIni); + template = update; + } + + resolve(mergedIni, resolver, template.toString()); + fileAccess.writeIniFile(mergedIni, workspace, true); + LOG.trace("Saved merged ini to: {}", workspace); + } + + /** + * Merge the properties from {@code source} into {@code target}. Keys that exist in both: target gets overwritten with source. Keys that exist only in target: + * preserved (user modifications). + * + * @param source the source INI (update template). + * @param target the target INI (workspace or setup base). + */ + private void mergeIniInto(IniFile source, IniFile target) { + for (String sectionName : source.getSectionNames()) { + IniSection srcSection = source.getSection(sectionName); + IniSection tgtSection = target.getOrCreateSection(sectionName); + for (String key : srcSection.getPropertyKeys()) { + tgtSection.setProperty(key, srcSection.getPropertyValue(key)); + } + } + IniSection srcInitial = source.getInitialSection(); + IniSection tgtInitial = target.getOrCreateSection(""); + for (String key : srcInitial.getPropertyKeys()) { + tgtInitial.setProperty(key, srcInitial.getPropertyValue(key)); + } + } + + private void resolve(IniFile iniFile, EnvironmentVariables resolver, String src) { + + for (String sectionName : iniFile.getSectionNames()) { + IniSection section = iniFile.getSection(sectionName); + for (String key : section.getPropertyKeys()) { + String value = section.getPropertyValue(key); + String resolved = resolver.resolve(value, src, this.legacySupport); + section.setProperty(key, resolved); + } + } + IniSection initial = iniFile.getInitialSection(); + for (String key : initial.getPropertyKeys()) { + String value = initial.getPropertyValue(key); + String resolved = resolver.resolve(value, src, this.legacySupport); + initial.setProperty(key, resolved); + } + } + + @Override + public void inverseMerge(Path workspace, EnvironmentVariables variables, boolean addNewProperties, Path update) { + + if (!Files.exists(workspace)) { + LOG.trace("Workspace file does not exist: {}", workspace); + return; + } + if (!Files.exists(update)) { + LOG.trace("Update file does not exist: {}", update); + return; + } + Object src = workspace.getFileName(); + FileAccess fileAccess = this.context.getFileAccess(); + IniFile updateIni = fileAccess.readIniFile(update); + IniFile workspaceIni = fileAccess.readIniFile(workspace); + IniFile mergedIni = new IniFileImpl(); + copyIniInto(updateIni, mergedIni); + boolean updated = false; + + for (String sectionName : workspaceIni.getSectionNames()) { + IniSection wsSection = workspaceIni.getSection(sectionName); + IniSection mergedSection = mergedIni.getOrCreateSection(sectionName); + for (String key : wsSection.getPropertyKeys()) { + String wsValue = wsSection.getPropertyValue(key); + String updateValue = mergedSection.getPropertyValue(key); + if ((updateValue != null) || addNewProperties) { + String updateValueResolved = updateValue != null + ? variables.resolve(updateValue, src, this.legacySupport) + : null; + if (!wsValue.equals(updateValueResolved)) { + String wsValueInverseResolved = variables.inverseResolve(wsValue, src); + mergedSection.setProperty(key, wsValueInverseResolved); + updated = true; + } + } + } + } + + IniSection wsInitial = workspaceIni.getInitialSection(); + IniSection mergedInitial = mergedIni.getOrCreateSection(""); + for (String key : wsInitial.getPropertyKeys()) { + String wsValue = wsInitial.getPropertyValue(key); + String updateValue = mergedInitial.getPropertyValue(key); + if ((updateValue != null) || addNewProperties) { + String updateValueResolved = updateValue != null + ? variables.resolve(updateValue, src, this.legacySupport) + : null; + if (!wsValue.equals(updateValueResolved)) { + String wsValueInverseResolved = variables.inverseResolve(wsValue, src); + mergedInitial.setProperty(key, wsValueInverseResolved); + updated = true; + } + } + } + + if (updated) { + fileAccess.writeIniFile(mergedIni, update, true); + LOG.debug("Saved changes from: {} to: {}", workspace.getFileName(), update); + } else { + LOG.trace("No changes for: {}", update); + } + } + + @Override + protected boolean doUpgrade(Path workspaceFile) throws Exception { + + return doUpgradeTextContent(workspaceFile); + } + + /** + * Copy all sections and properties from source INI to target INI. + * + * @param src the source INI. + * @param tgt the target INI. + */ + private void copyIniInto(IniFile src, IniFile tgt) { + for (String sectionName : src.getSectionNames()) { + IniSection srcSection = src.getSection(sectionName); + IniSection tgtSection = tgt.getOrCreateSection(sectionName); + for (String key : srcSection.getPropertyKeys()) { + tgtSection.setProperty(key, srcSection.getPropertyValue(key)); + } + } + IniSection srcInit = src.getInitialSection(); + IniSection tgtInit = tgt.getOrCreateSection(""); + for (String key : srcInit.getPropertyKeys()) { + tgtInit.setProperty(key, srcInit.getPropertyValue(key)); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java index 23dd53023f..8daba6592a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java @@ -4,13 +4,13 @@ import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; +import com.devonfw.tools.ide.tool.plugin.PluginFeatures; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; import com.devonfw.tools.ide.tool.plugin.ToolPlugins; import com.devonfw.tools.ide.validation.PropertyValidator; /** - * {@link Property} representing the plugin of a {@link PluginBasedCommandlet}. + * {@link Property} representing the plugin of a {@link PluginFeatures tool that supports plugins}. */ public class PluginProperty extends Property { @@ -56,7 +56,7 @@ public String parse(String valueAsString, IdeContext context) { protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { ToolCommandlet cmd = commandlet.getToolForCompletion(); - if (cmd instanceof PluginBasedCommandlet pbc) { + if (cmd instanceof PluginFeatures pbc) { ToolPlugins plugins = pbc.getPlugins(); for (ToolPluginDescriptor pluginDescriptor : plugins.getPlugins()) { if (pluginDescriptor.name().toLowerCase().startsWith(arg.toLowerCase())) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java b/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java index 461ee842d2..39b83bd42e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java @@ -84,7 +84,7 @@ protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, Lis } @Override - protected boolean isPluginUrlNeeded() { + public boolean isPluginUrlNeeded() { return true; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeFeatures.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeFeatures.java new file mode 100644 index 0000000000..c958f3cb01 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeFeatures.java @@ -0,0 +1,32 @@ +package com.devonfw.tools.ide.tool.ide; + +import java.nio.file.Path; + +/** + * Interface for IDE-specific features that are independent of the installation mechanism (binary vs. package manager). + *

+ * This allows tools installed via package managers (like pip for Spyder) to still benefit from IDEasy's IDE features such as workspace configuration, metadata + * management, and repository import. + */ +public interface IdeFeatures { + + /** + * Configures (initializes or updates) the workspace for this IDE using the templates from the settings. + */ + void configureWorkspace(); + + /** + * @return the {@link Path} to the IDE-specific metadata folder for the current workspace, located at {@code $IDE_HOME/.ide/«toolName»/«workspace»}. Unlike + * {@link com.devonfw.tools.ide.context.IdeContext#getWorkspacePath() the workspace path} (which holds the projects to open), this folder keeps + * IDE-specific metadata (e.g. {@code .vmoptions} or {@code *.properties} files) out of the workspace so it stays clean and independent of the IDE being + * used. + */ + Path getIdeMetadataPath(); + + /** + * Imports the repository specified by the given {@link Path} into the IDE managed by this {@link IdeFeatures}. + * + * @param repositoryPath the {@link Path} to the repository directory to import. + */ + void importRepository(Path repositoryPath); +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java index f30f44b851..fbec1ed1fd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java @@ -1,38 +1,19 @@ package com.devonfw.tools.ide.tool.ide; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; - import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariables; -import com.devonfw.tools.ide.environment.ExtensibleEnvironmentVariables; -import com.devonfw.tools.ide.io.FileAccess; -import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.merge.xml.XmlMergeDocument; -import com.devonfw.tools.ide.merge.xml.XmlMerger; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.eclipse.Eclipse; -import com.devonfw.tools.ide.tool.extra.ExtraToolInstallation; -import com.devonfw.tools.ide.tool.extra.ExtraTools; -import com.devonfw.tools.ide.tool.extra.ExtraToolsMapper; import com.devonfw.tools.ide.tool.intellij.Intellij; import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; import com.devonfw.tools.ide.tool.vscode.Vscode; @@ -40,12 +21,11 @@ /** * {@link ToolCommandlet} for an IDE (integrated development environment) such as {@link Eclipse}, {@link Vscode}, or {@link Intellij}. */ -public abstract class IdeToolCommandlet extends PluginBasedCommandlet { - - private static final Logger LOG = LoggerFactory.getLogger(IdeToolCommandlet.class); +public abstract class IdeToolCommandlet extends PluginBasedCommandlet implements IdeFeatures { private static final String OPTIONS_ENV_SUFFIX = "_OPTIONS"; - private final Map> extraSdkMap; + + private final IdeWorkspaceConfigurer workspaceConfigurer; /** * The constructor. @@ -58,7 +38,7 @@ public IdeToolCommandlet(IdeContext context, String tool, Set tags) { super(context, tool, tags); assert (hasIde(tags)); - this.extraSdkMap = new HashMap<>(); + this.workspaceConfigurer = new IdeWorkspaceConfigurer(context, tool); } private boolean hasIde(Set tags) { @@ -122,7 +102,8 @@ protected void postInstall(ToolInstallRequest request) { * folder keeps IDE-specific metadata (e.g. {@code .vmoptions} or {@code *.properties} files) out of the workspace so it stays clean and independent of * the IDE being used. */ - protected Path getIdeMetadataPath() { + @Override + public Path getIdeMetadataPath() { return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(getName()).resolve(this.context.getWorkspaceName()); } @@ -130,75 +111,19 @@ protected Path getIdeMetadataPath() { /** * Configure (initialize or update) the workspace for this IDE using the templates from the settings. */ + @Override public void configureWorkspace() { - FileAccess fileAccess = this.context.getFileAccess(); - Path workspaceFolder = this.context.getWorkspacePath(); - if (!fileAccess.isExpectedFolder(workspaceFolder)) { - LOG.warn("Current workspace does not exist: {}", workspaceFolder); - return; // should actually never happen... - } - Step step = this.context.newStep("Configuring workspace " + workspaceFolder.getFileName() + " for IDE " + this.tool); - step.run(() -> doMergeWorkspaceStep(step, workspaceFolder)); - } - - private void doMergeWorkspaceStep(Step step, Path workspaceFolder) { - - int errors = 0; - errors = mergeWorkspace(this.context.getUserHomeIde(), workspaceFolder, errors); - errors = mergeWorkspace(this.context.getSettingsPath(), workspaceFolder, errors); - errors = mergeWorkspace(this.context.getConfPath(), workspaceFolder, errors); - - synchronizeExtraToolInstallations(); - - if (errors == 0) { - step.success(); - } else { - step.error("Your workspace configuration failed with {} error(s) - see log above.\n" - + "This is either a configuration error in your settings git repository or a bug in IDEasy.\n" - + "Please analyze the above errors with your team or IDE-admin and try to fix the problem.", errors); - this.context.askToContinue( - "In order to prevent you from being blocked, you can start your IDE anyhow but some configuration may not be in sync."); - } - } - - private int mergeWorkspace(Path configFolder, Path workspaceFolder, int errors) { - - int result = errors; - result = mergeWorkspaceSingle(configFolder.resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); - result = mergeWorkspaceSingle(configFolder.resolve(this.tool).resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); - return result; - } - - private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, int errors) { - - Path setupFolder = templatesFolder.resolve(IdeContext.FOLDER_SETUP); - Path updateFolder = templatesFolder.resolve(IdeContext.FOLDER_UPDATE); - if (!Files.isDirectory(setupFolder) && !Files.isDirectory(updateFolder)) { - LOG.trace("Skipping empty or non-existing workspace template folder {}.", templatesFolder); - return errors; - } - LOG.debug("Merging workspace templates from {}...", templatesFolder); - return errors + this.context.getWorkspaceMerger().merge(setupFolder, updateFolder, this.context.getVariables(), workspaceFolder); - } - - /** - * Imports the repository specified by the given {@link Path} into the IDE managed by this {@link IdeToolCommandlet}. - * - * @param repositoryPath the {@link Path} to the repository directory to import. - */ - public void importRepository(Path repositoryPath) { - - throw new UnsupportedOperationException("Repository import is not yet implemented for IDE " + this.tool); + this.workspaceConfigurer.configureWorkspace(); } /** * Registers support for synchronizing an extra SDK/template for this IDE. * *

- * The registered template path must be relative to the IDE workspace root. During workspace synchronization, the generic extra-SDK handling in - * {@link #synchronizeExtraToolInstallations()} uses this mapping to locate the corresponding template file in the settings repository and merge it into the - * current workspace. + * The registered template path must be relative to the IDE workspace root. During workspace synchronization, the generic extra-SDK handling performed by + * the {@link IdeWorkspaceConfigurer} uses this mapping to locate the corresponding template file in the settings repository and merge it into the current + * workspace. *

* * @param sdk the name of the extra SDK/tool as configured in {@code ide-extra-tools.json}. @@ -206,75 +131,17 @@ public void importRepository(Path repositoryPath) { */ protected void registerExtraSdkTemplate(String sdk, Path relativeTemplatePath) { - Set templatePaths = this.extraSdkMap.computeIfAbsent(sdk, _ -> new HashSet<>()); - templatePaths.add(relativeTemplatePath); + this.workspaceConfigurer.registerExtraSdkTemplate(sdk, relativeTemplatePath); } /** - * Synchronizes extra IDEasy tool installations into the current IDE workspace configuration if supported. + * Imports the repository specified by the given {@link Path} into the IDE managed by this {@link IdeToolCommandlet}. * - *

- * By default, nothing will happen. Your IDE commandlet has to register one or more according templates in its constructor. - *

+ * @param repositoryPath the {@link Path} to the repository directory to import. */ - protected void synchronizeExtraToolInstallations() { - - ExtraTools extraTools = ExtraToolsMapper.get().loadJsonFromFolder(this.context.getSettingsPath()); - if (extraTools == null) { - return; - } - for (String sdk : extraTools.getSortedToolNames()) { - Set templatePaths = this.extraSdkMap.get(sdk); - if ((templatePaths == null) || templatePaths.isEmpty()) { - LOG.debug("Skipping import of extra tool {} into {} because not configured or supported.", sdk, this.tool); - continue; - } - List extraInstallations = extraTools.getExtraInstallations(sdk); - synchronizeExtraToolInstallation(sdk, templatePaths, extraInstallations); - } - } - - private void synchronizeExtraToolInstallation(String sdk, Set templatePaths, List extraInstallations) { - - for (Path templatePath : templatePaths) { - Path workspaceFile = this.context.getWorkspacePath().resolve(templatePath); - Path templateFile = this.context.getSettingsPath().resolve(this.tool).resolve(IdeContext.FOLDER_WORKSPACE) - .resolve(IdeContext.FOLDER_REPOSITORY) - .resolve(templatePath); - if (Files.exists(templateFile)) { - for (ExtraToolInstallation extraInstallation : extraInstallations) { - synchronizeExtraToolInstallation(sdk, templateFile, workspaceFile, extraInstallation); - } - } else { - LOG.warn("You are missing a template file at {}.", templatePath); - IdeLogLevel.INTERACTION.log(LOG, "Please ask the IDEasy admin in your project to merge your settings with upstream."); - } - } - } - - private void synchronizeExtraToolInstallation(String sdk, Path templateFile, Path workspaceFile, ExtraToolInstallation installation) { - - String name = installation.name(); - Path extraToolHome = this.context.getSoftwareExtraPath().resolve(sdk).resolve(name); - if (!Files.isDirectory(extraToolHome)) { - LOG.warn("Skipping extra tool installation import to {} because it is missing at {}", this.tool, extraToolHome); - IdeLogLevel.INTERACTION.log(LOG, "Please run the following command to fix:\nide update"); - return; - } - ExtensibleEnvironmentVariables environmentVariables = new ExtensibleEnvironmentVariables( - (AbstractEnvironmentVariables) this.context.getVariables().getParent(), this.context); - String variablePrefix = "EXTRA_" + sdk.toUpperCase(Locale.ROOT); - environmentVariables.setValue(variablePrefix + "_NAME", name); - environmentVariables.setValue(variablePrefix + "_HOME", extraToolHome.toString().replace('\\', '/')); - environmentVariables.setValue(variablePrefix + "_VERSION", installation.version().toString()); - if (installation.edition() != null) { - environmentVariables.setValue(variablePrefix + "_EDITION", installation.edition()); - } + @Override + public void importRepository(Path repositoryPath) { - XmlMerger xmlMerger = new XmlMerger(this.context); - XmlMergeDocument workspaceDocument = xmlMerger.load(workspaceFile); - XmlMergeDocument templateDocument = xmlMerger.loadAndResolve(templateFile, environmentVariables); - Document mergedDocument = xmlMerger.merge(templateDocument, workspaceDocument, false); - xmlMerger.save(mergedDocument, workspaceFile); + throw new UnsupportedOperationException("Repository import is not yet implemented for IDE " + this.tool); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java new file mode 100644 index 0000000000..52ff66ae57 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java @@ -0,0 +1,186 @@ +package com.devonfw.tools.ide.tool.ide; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables; +import com.devonfw.tools.ide.environment.ExtensibleEnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.merge.xml.XmlMergeDocument; +import com.devonfw.tools.ide.merge.xml.XmlMerger; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.extra.ExtraToolInstallation; +import com.devonfw.tools.ide.tool.extra.ExtraTools; +import com.devonfw.tools.ide.tool.extra.ExtraToolsMapper; + +/** + * Configures IDE workspaces by merging templates from settings repositories. + *

+ * This class encapsulates the workspace configuration logic that is shared between all IDEs, regardless of their installation mechanism (binary, pip, npm, + * etc.). It can be used via composition by any {@link com.devonfw.tools.ide.tool.ToolCommandlet} that needs IDE workspace configuration capabilities. + */ +public class IdeWorkspaceConfigurer { + + private static final Logger LOG = LoggerFactory.getLogger(IdeWorkspaceConfigurer.class); + + private final IdeContext context; + private final String toolName; + private final Map> extraSdkMap; + + /** + * Creates a new workspace configurer for the given IDE tool. + * + * @param context the {@link IdeContext}. + * @param toolName the name of the IDE tool (e.g. "intellij", "spyder", "vscode"). + */ + public IdeWorkspaceConfigurer(IdeContext context, String toolName) { + this.context = context; + this.toolName = toolName; + this.extraSdkMap = new HashMap<>(); + } + + /** + * Registers support for synchronizing an extra SDK/template for this IDE. + * + *

+ * The registered template path must be relative to the IDE workspace root. During workspace synchronization, the generic extra-SDK handling in + * {@link #synchronizeExtraToolInstallations()} uses this mapping to locate the corresponding template file in the settings repository and merge it into the + * current workspace. + *

+ * + * @param sdk the name of the extra SDK/tool as configured in {@code ide-extra-tools.json}. + * @param relativeTemplatePath the workspace-relative path of the IDE-specific template file to merge. + */ + public void registerExtraSdkTemplate(String sdk, Path relativeTemplatePath) { + + Set templatePaths = this.extraSdkMap.computeIfAbsent(sdk, _ -> new HashSet<>()); + templatePaths.add(relativeTemplatePath); + } + + /** + * Configure (initialize or update) the workspace for this IDE using the templates from the settings. + */ + public void configureWorkspace() { + FileAccess fileAccess = this.context.getFileAccess(); + Path workspaceFolder = this.context.getWorkspacePath(); + if (!fileAccess.isExpectedFolder(workspaceFolder)) { + LOG.warn("Current workspace does not exist: {}", workspaceFolder); + return; // should actually never happen... + } + Step step = this.context.newStep("Configuring workspace " + workspaceFolder.getFileName() + " for IDE " + this.toolName); + step.run(() -> doMergeWorkspaceStep(step, workspaceFolder)); + } + + private void doMergeWorkspaceStep(Step step, Path workspaceFolder) { + + int errors = 0; + errors = mergeWorkspace(this.context.getUserHomeIde(), workspaceFolder, errors); + errors = mergeWorkspace(this.context.getSettingsPath(), workspaceFolder, errors); + errors = mergeWorkspace(this.context.getConfPath(), workspaceFolder, errors); + + synchronizeExtraToolInstallations(); + + if (errors == 0) { + step.success(); + } else { + step.error("Your workspace configuration failed with {} error(s) - see log above.\n" + + "This is either a configuration error in your settings git repository or a bug in IDEasy.\n" + + "Please analyze the above errors with your team or IDE-admin and try to fix the problem.", errors); + this.context.askToContinue( + "In order to prevent you from being blocked, you can start your IDE anyhow but some configuration may not be in sync."); + } + } + + private int mergeWorkspace(Path configFolder, Path workspaceFolder, int errors) { + + int result = errors; + result = mergeWorkspaceSingle(configFolder.resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); + result = mergeWorkspaceSingle(configFolder.resolve(this.toolName).resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); + return result; + } + + private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, int errors) { + + Path setupFolder = templatesFolder.resolve(IdeContext.FOLDER_SETUP); + Path updateFolder = templatesFolder.resolve(IdeContext.FOLDER_UPDATE); + if (!Files.isDirectory(setupFolder) && !Files.isDirectory(updateFolder)) { + LOG.trace("Skipping empty or non-existing workspace template folder {}.", templatesFolder); + return errors; + } + LOG.debug("Merging workspace templates from {}...", templatesFolder); + return errors + this.context.getWorkspaceMerger().merge(setupFolder, updateFolder, this.context.getVariables(), workspaceFolder); + } + + private void synchronizeExtraToolInstallations() { + + ExtraTools extraTools = ExtraToolsMapper.get().loadJsonFromFolder(this.context.getSettingsPath()); + if (extraTools == null) { + return; + } + for (String sdk : extraTools.getSortedToolNames()) { + Set templatePaths = this.extraSdkMap.get(sdk); + if ((templatePaths == null) || templatePaths.isEmpty()) { + LOG.debug("Skipping import of extra tool {} into {} because not configured or supported.", sdk, this.toolName); + continue; + } + List extraInstallations = extraTools.getExtraInstallations(sdk); + synchronizeExtraToolInstallation(sdk, templatePaths, extraInstallations); + } + } + + private void synchronizeExtraToolInstallation(String sdk, Set templatePaths, List extraInstallations) { + + for (Path templatePath : templatePaths) { + Path workspaceFile = this.context.getWorkspacePath().resolve(templatePath); + Path templateFile = this.context.getSettingsPath().resolve(this.toolName).resolve(IdeContext.FOLDER_WORKSPACE) + .resolve(IdeContext.FOLDER_REPOSITORY) + .resolve(templatePath); + if (Files.exists(templateFile)) { + for (ExtraToolInstallation extraInstallation : extraInstallations) { + synchronizeExtraToolInstallation(sdk, templateFile, workspaceFile, extraInstallation); + } + } else { + LOG.warn("You are missing a template file at {}.", templatePath); + IdeLogLevel.INTERACTION.log(LOG, "Please ask the IDEasy admin in your project to merge your settings with upstream."); + } + } + } + + private void synchronizeExtraToolInstallation(String sdk, Path templateFile, Path workspaceFile, ExtraToolInstallation installation) { + + String name = installation.name(); + Path extraToolHome = this.context.getSoftwareExtraPath().resolve(sdk).resolve(name); + if (!Files.isDirectory(extraToolHome)) { + LOG.warn("Skipping extra tool installation import to {} because it is missing at {}", this.toolName, extraToolHome); + IdeLogLevel.INTERACTION.log(LOG, "Please run the following command to fix:\nide update"); + return; + } + ExtensibleEnvironmentVariables environmentVariables = new ExtensibleEnvironmentVariables( + (AbstractEnvironmentVariables) this.context.getVariables().getParent(), this.context); + String variablePrefix = "EXTRA_" + sdk.toUpperCase(Locale.ROOT); + environmentVariables.setValue(variablePrefix + "_NAME", name); + environmentVariables.setValue(variablePrefix + "_HOME", extraToolHome.toString().replace('\\', '/')); + environmentVariables.setValue(variablePrefix + "_VERSION", installation.version().toString()); + if (installation.edition() != null) { + environmentVariables.setValue(variablePrefix + "_EDITION", installation.edition()); + } + + XmlMerger xmlMerger = new XmlMerger(this.context); + XmlMergeDocument workspaceDocument = xmlMerger.load(workspaceFile); + XmlMergeDocument templateDocument = xmlMerger.loadAndResolve(templateFile, environmentVariables); + Document mergedDocument = xmlMerger.merge(templateDocument, workspaceDocument, false); + xmlMerger.save(mergedDocument, workspaceFile); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java index 30c8be9a3a..9ebc0920e0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java @@ -1,17 +1,48 @@ package com.devonfw.tools.ide.tool.pip; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; import java.util.List; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.property.FlagProperty; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.PackageManagerRequest; +import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.ide.IdeFeatures; +import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; +import com.devonfw.tools.ide.tool.ide.IdeWorkspaceConfigurer; +import com.devonfw.tools.ide.tool.plugin.PluginFeatures; +import com.devonfw.tools.ide.tool.plugin.PluginManager; +import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; +import com.devonfw.tools.ide.tool.plugin.ToolPlugins; +import com.devonfw.tools.ide.version.VersionIdentifier; /** - * Base class for pip-based IDE tools that should launch in the background instead of blocking the terminal. + * Base class for pip-based IDE tools that should launch in the background instead of blocking the terminal. Implements {@link IdeFeatures} to provide IDE + * workspace configuration capabilities and {@link PluginFeatures} for plugin management. */ -public abstract class PipBasedIdeToolCommandlet extends PipBasedCommandlet { +public abstract class PipBasedIdeToolCommandlet extends PipBasedCommandlet implements IdeFeatures, PluginFeatures { + + private static final Logger LOG = LoggerFactory.getLogger(PipBasedIdeToolCommandlet.class); + + private final IdeWorkspaceConfigurer workspaceConfigurer; + + private final PluginManager pluginManager; + + /** {@link FlagProperty} to force the reset and reinstallation of plugins as configured in the project settings. */ + public FlagProperty forcePluginReinstall; /** * The constructor. @@ -22,10 +53,181 @@ public abstract class PipBasedIdeToolCommandlet extends PipBasedCommandlet { */ public PipBasedIdeToolCommandlet(IdeContext context, String tool, Set tags) { super(context, tool, tags); + this.workspaceConfigurer = new IdeWorkspaceConfigurer(context, tool); + this.pluginManager = new PluginManager(context, this); + } + + @Override + protected void initProperties() { + this.forcePluginReinstall = add(new FlagProperty("--force-plugin-reinstall")); + super.initProperties(); } @Override public ProcessResult runTool(List args) { + configureWorkspace(); return runTool(ProcessMode.BACKGROUND, null, args); } + + @Override + public void configureWorkspace() { + this.workspaceConfigurer.configureWorkspace(); + } + + @Override + public Path getIdeMetadataPath() { + + return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(getName()).resolve(this.context.getWorkspaceName()); + } + + /** + * Imports the repository specified by the given {@link Path} into the IDE managed by this commandlet. + * + * @param repositoryPath the {@link Path} to the repository directory to import. + */ + @Override + public void importRepository(Path repositoryPath) { + throw new UnsupportedOperationException("Repository import is not yet implemented for IDE " + this.tool); + } + + /** + * @return the {@link PluginManager} implementing the plugin logic of this {@link PipBasedIdeToolCommandlet}. + */ + protected PluginManager getPluginManager() { + + return this.pluginManager; + } + + @Override + public ToolPlugins getPlugins() { + + return this.pluginManager.getPlugins(); + } + + @Override + public ToolPluginDescriptor getPlugin(String key) { + return this.pluginManager.getPlugin(key); + } + + @Override + public boolean isPluginUrlNeeded() { + + return false; + } + + @Override + public Path getPluginsConfigPath() { + + return this.context.getSettingsPath().resolve(this.tool).resolve(IdeContext.FOLDER_PLUGINS); + } + + /** + * @return the {@link Path} to the python environment the plugins are installed into. Unlike for an {@link IdeToolCommandlet} this is not a folder owned by + * this tool but the shared python environment (containing {@code site-packages}) that also holds th IDE itself. It must therefore never be delted - + * plugins are removed via {@link #uninstallPlugin(ToolPluginDescriptor)} instead. + */ + @Override + public Path getPluginsInstallationPath() { + + return getParentTool().getToolPath(); + } + + @Override + protected void postInstall(ToolInstallRequest request) { + + super.postInstall(request); + if (!request.isAlreadyInstalled() || this.forcePluginReinstall.isTrue()) { + this.pluginManager.resetPlugins(); + } + installPlugins(getPlugins().getPlugins(), request.getProcessContext()); + } + + @Override + public void installPlugins(Collection plugins, ProcessContext pc) { + + this.pluginManager.installPlugins(plugins, pc); + } + + @Override + public boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessContext pc) { + ProcessResult result = runPluginPackageManager(PackageManagerRequest.TYPE_INSTALL, plugin, pc); + if (result.isSuccessful()) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully installed plugin: {}", plugin.name()); + step.success(); + return true; + } + result.log(IdeLogLevel.DEBUG, IdeLogLevel.ERROR); + step.error("Failed to install plugin {} ({}): exit code war {}", plugin.name(), plugin.id(), result.getExitCode()); + return false; + } + + @Override + public void installPlugin(ToolPluginDescriptor plugin, final Step step) { + + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.THROW_CLI); + ToolInstallRequest request = new ToolInstallRequest(true); + request.setProcessContext(pc); + install(request); + installPlugin(plugin, step, pc); + } + + @Override + public void uninstallPlugin(ToolPluginDescriptor plugin) { + + ProcessResult result = runPluginPackageManager(PackageManagerRequest.TYPE_UNINSTALL, plugin, null); + if (result.isSuccessful()) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully uninstalled plugin {}", plugin.name()); + } else { + result.log(IdeLogLevel.DEBUG, IdeLogLevel.ERROR); + LOG.error("Could not uninstall plugin {} ({}): exit code was {}", plugin.name(), plugin.id(), result.getExitCode()); + } + } + + @Override + public void deleteAllPlugins() { + + for (ToolPluginDescriptor plugin : getPlugins().getPlugins()) { + Path markerFile = retrievePluginMarkerFilePath(plugin); + if ((markerFile != null) && Files.exists(markerFile)) { + uninstallPlugin(plugin); + } + } + } + + /** + * @param type the {@link PackageManagerRequest#getType() type} of the request ({@link PackageManagerRequest#TYPE_INSTALL install} or + * {@link PackageManagerRequest#TYPE_UNINSTALL uninstall}). + * @param plugin the {@link ToolPluginDescriptor} to install or uninstall. Its {@link ToolPluginDescriptor#id() ID} is used as name of the python + * package. + * @param pc the {@link ProcessContext} to use or {@code null} to create a new one. + * @return the {@link ProcessResult} + */ + private ProcessResult runPluginPackageManager(String type, ToolPluginDescriptor plugin, ProcessContext pc) { + + PackageManagerRequest request = new PackageManagerRequest(type, plugin.id()).setProcessMode(ProcessMode.DEFAULT_CAPTURE).setProcessContext(pc); + if (PackageManagerRequest.TYPE_INSTALL.equals(type)) { + String version = plugin.version(); + if ((version != null) && !version.isBlank()) { + request.setVersion(VersionIdentifier.of(version)); + } + } + return runPackageManager(request); + } + + @Override + public Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin) { + + return this.pluginManager.retrievePluginMarkerFilePath(plugin); + } + + @Override + public void createPluginMarkerFile(ToolPluginDescriptor plugin) { + + this.pluginManager.createPluginMarkerFile(plugin); + } + + @Override + public void handleInstallForInactivePlugin(ToolPluginDescriptor plugin) { + LOG.debug("Omitting installation of inactive plugin {} ({}).", plugin.name(), plugin.id()); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java index efc7a3c813..93a81ce645 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java @@ -1,38 +1,29 @@ package com.devonfw.tools.ide.tool.plugin; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.List; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.environment.EnvironmentVariables; -import com.devonfw.tools.ide.environment.VariableLine; -import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; /** * Base class for {@link LocalToolCommandlet}s that support plugins. It can automatically install configured plugins for the tool managed by this commandlet. */ -public abstract class PluginBasedCommandlet extends LocalToolCommandlet { +public abstract class PluginBasedCommandlet extends LocalToolCommandlet implements PluginFeatures { private static final Logger LOG = LoggerFactory.getLogger(PluginBasedCommandlet.class); - private ToolPlugins plugins; + private final PluginManager pluginManager; /** {@link FlagProperty} to force the reset and reinstallation of plugins as configured in the project settings. */ public FlagProperty forcePluginReinstall; @@ -47,320 +38,120 @@ public abstract class PluginBasedCommandlet extends LocalToolCommandlet { public PluginBasedCommandlet(IdeContext context, String tool, Set tags) { super(context, tool, tags); + this.pluginManager = new PluginManager(context, this); } @Override protected void initProperties() { + this.forcePluginReinstall = add(new FlagProperty("--force-plugin-reinstall")); super.initProperties(); } /** - * @return the {@link ToolPlugins} of this {@link PluginBasedCommandlet}. + * @return the {@link PluginManager} for this tool. */ - public ToolPlugins getPlugins() { - - if (this.plugins == null) { - ToolPlugins toolPlugins = new ToolPlugins(); - - // Load project-specific plugins - Path pluginsPath = getPluginsConfigPath(); - loadPluginsFromDirectory(toolPlugins, pluginsPath); - - // Load user-specific plugins, this is done after loading the project-specific plugins so the user can potentially - // override plugins (e.g. change active flag). - Path userPluginsPath = getUserHomePluginsConfigPath(); - loadPluginsFromDirectory(toolPlugins, userPluginsPath); - - this.plugins = toolPlugins; - } + protected PluginManager getPluginManager() { - return this.plugins; + return this.pluginManager; } - private void loadPluginsFromDirectory(ToolPlugins map, Path pluginsPath) { + @Override + public ToolPlugins getPlugins() { - List children = this.context.getFileAccess() - .listChildren(pluginsPath, p -> p.getFileName().toString().endsWith(IdeContext.EXT_PROPERTIES)); - for (Path child : children) { - ToolPluginDescriptor descriptor = ToolPluginDescriptor.of(child, this.context, isPluginUrlNeeded()); - map.add(descriptor); - } + return this.pluginManager.getPlugins(); } - /** - * @return {@code true} if {@link ToolPluginDescriptor#url() plugin URL} property is needed, {@code false} otherwise. - */ - protected boolean isPluginUrlNeeded() { + @Override + public boolean isPluginUrlNeeded() { return false; } - /** - * @return the {@link Path} to the folder with the plugin configuration files inside the settings. - */ - protected Path getPluginsConfigPath() { + @Override + public Path getPluginsConfigPath() { return this.context.getSettingsPath().resolve(this.tool).resolve(IdeContext.FOLDER_PLUGINS); } - private Path getUserHomePluginsConfigPath() { - - return this.context.getUserHomeIde().resolve(IdeContext.FOLDER_SETTINGS).resolve(this.tool).resolve(IdeContext.FOLDER_PLUGINS); - } - - /** - * @return the {@link Path} where the plugins of this {@link IdeToolCommandlet} shall be installed. - */ - public Path getPluginsInstallationPath() { - - return this.context.getPluginsPath().resolve(this.tool); - } - @Override protected void postInstall(ToolInstallRequest request) { super.postInstall(request); - Path pluginsInstallationPath = getPluginsInstallationPath(); - if (!request.isAlreadyInstalled() || this.forcePluginReinstall.isTrue()) { - LOG.info("Resetting all installed plugins..."); - deleteAllPlugins(pluginsInstallationPath); + this.pluginManager.resetPlugins(); } - this.context.getFileAccess().mkdirs(pluginsInstallationPath); - installPlugins(request.getProcessContext()); + this.context.getFileAccess().mkdirs(getPluginsInstallationPath()); + installPlugins(getPlugins().getPlugins(), request.getProcessContext()); } - /** - * Deletes all installed plugins for this {@link IdeToolCommandlet} by deleting the plugins installation folder and all plugin marker files. - * - * @param pluginsInstallationPath the {@link Path} to the plugins installation folder. - */ - private void deleteAllPlugins(Path pluginsInstallationPath) { - - FileAccess fileAccess = this.context.getFileAccess(); - fileAccess.delete(pluginsInstallationPath); - List markerFiles = fileAccess.listChildren(this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE), Files::isRegularFile); - for (Path path : markerFiles) { - if (path.getFileName().toString().startsWith("plugin." + getName())) { - fileAccess.delete(path); - LOG.debug("Plugin marker file {} got deleted.", path); - } - } - } - - private void installPlugins(ProcessContext pc) { - installPlugins(getPlugins().getPlugins(), pc); - } + @Override + public void installPlugins(Collection plugins, ProcessContext pc) { - /** - * Method to install active plugins or to handle install for inactive plugins - * - * @param plugins as {@link Collection} of plugins to install. - * @param pc the {@link ProcessContext} to use. - */ - protected void installPlugins(Collection plugins, ProcessContext pc) { - - Set extraPlugins = getExtraPlugins(plugins); - String edition = getConfiguredEdition(); - List pluginsToInstall = new ArrayList<>(plugins.size()); - for (ToolPluginDescriptor plugin : plugins) { - if (plugin.excludedEditions().contains(edition)) { - LOG.debug("Skipping plugin '{}' (excluded for edition '{}').", plugin.name(), edition); - } else if (plugin.active() || extraPlugins.contains(plugin.name())) { - pluginsToInstall.add(plugin); - } else { - Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); - if ((pluginMarkerFile == null) || !Files.exists(pluginMarkerFile)) { - handleInstallForInactivePlugin(plugin); - } - } - } - int currentPluginIndex = 1; - int totalPlugins = pluginsToInstall.size(); - for (ToolPluginDescriptor plugin : pluginsToInstall) { - Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); - boolean pluginMarkerFileExists = (pluginMarkerFile != null) && Files.exists(pluginMarkerFile); - if (pluginMarkerFileExists) { - LOG.debug("Markerfile for IDE {} and plugin '{}' already exists.", getName(), plugin.name()); - } - if (this.context.isForcePlugins() || !pluginMarkerFileExists) { - String progressMarker = " (" + currentPluginIndex + "/" + totalPlugins + ")"; - Step step = this.context.newStep("Install plugin " + plugin.name() + progressMarker); - step.run(() -> doInstallPluginStep(plugin, step, pc)); - } else { - LOG.debug("Skipping installation of plugin '{}' due to existing marker file: {}", plugin.name(), pluginMarkerFile); - } - currentPluginIndex++; - } + this.pluginManager.installPlugins(plugins, pc); } /** * @param plugins the configured {@link ToolPluginDescriptor plugins} used to detect undefined entries. * @return the {@link Set} of {@link ToolPluginDescriptor#name() plugin names} configured in the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable (e.g. - * {@code VSCODE_EXTRA_PLUGINS=copilot,docker}). This allows a user to permanently opt-in to plugins that are not {@link ToolPluginDescriptor#active() - * active} in the project settings, without modifying the shared settings and without losing them when plugins are purged and reinstalled on IDE upgrade. - * Values refer to the {@link ToolPluginDescriptor#name() name} of the plugin (the filename of its {@code .properties} file) and not to the - * {@link ToolPluginDescriptor#id() id}. Names that do not resolve to a configured plugin are logged as a warning and skipped so that a single stale entry - * cannot break the entire installation. + * {@code VSCODE_EXTRA_PLUGINS=copilot,docker}). This allows a user to permanently opt-in to plugins that are not + * {@link ToolPluginDescriptor#active() active} in the project settings, without modifying the shared settings and without losing them when plugins are + * purged and reinstalled on IDE upgrade. Values refer to the {@link ToolPluginDescriptor#name() name} of the plugin (the filename of its + * {@code .properties} file) and not to the {@link ToolPluginDescriptor#id() id}. Names that do not resolve to a configured plugin are logged as a warning + * and skipped so that a single stale entry cannot break the entire installation. */ protected Set getExtraPlugins(Collection plugins) { - String variable = EnvironmentVariables.getToolExtraPluginsVariable(this.tool); - String value = this.context.getVariables().get(variable); - if ((value == null) || value.isBlank()) { - return Set.of(); - } - Set extraPlugins = new LinkedHashSet<>(); - for (String name : VariableLine.parseArray(value)) { - if (name.endsWith(IdeContext.EXT_PROPERTIES)) { - name = name.substring(0, name.length() - IdeContext.EXT_PROPERTIES.length()); - } - extraPlugins.add(name); - } - Set undefinedPlugins = new LinkedHashSet<>(extraPlugins); - for (ToolPluginDescriptor plugin : plugins) { - undefinedPlugins.remove(plugin.name()); - } - for (String name : undefinedPlugins) { - LOG.info("Ignoring undefined plugin '{}' configured in variable {} - no file {}{} found in {} or {}.", name, variable, name, IdeContext.EXT_PROPERTIES, - getPluginsConfigPath(), getUserHomePluginsConfigPath()); - } - return extraPlugins; - } - - private void doInstallPluginStep(ToolPluginDescriptor plugin, Step step, ProcessContext pc) { - boolean result = installPlugin(plugin, step, pc); - if (result) { - createPluginMarkerFile(plugin); - } + return this.pluginManager.getExtraPlugins(plugins); } - /** - * @param plugin the {@link ToolPluginDescriptor plugin} to search for. - * @return Path to the plugin marker file. - */ + @Override public Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin) { - if (this.context.getIdeHome() != null) { - String markerFileName = "plugin" + "." + getName() + "." + getInstalledEdition() + "." + plugin.name(); - String version = plugin.version(); - if ((version != null) && !version.isBlank()) { - markerFileName = markerFileName + ".version-" + normalizeMarkerFileSegment(version); - } - return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(markerFileName); - } - return null; - } - private String normalizeMarkerFileSegment(String value) { - // replace all characters that are not allowed in filenames with "_" - return value.replaceAll("[^A-Za-z0-9._-]", "_"); + return this.pluginManager.retrievePluginMarkerFilePath(plugin); } - /** - * Creates a marker file for a plugin in $IDE_HOME/.ide/plugin.«ide».«plugin-name» - * - * @param plugin the {@link ToolPluginDescriptor plugin} for which the marker file should be created. - */ + @Override public void createPluginMarkerFile(ToolPluginDescriptor plugin) { - Path pluginMarkerFilePath = retrievePluginMarkerFilePath(plugin); - if (pluginMarkerFilePath != null) { - FileAccess fileAccess = this.context.getFileAccess(); - fileAccess.mkdirs(pluginMarkerFilePath.getParent()); - deleteExistingPluginMarkerFiles(fileAccess, plugin, pluginMarkerFilePath); - fileAccess.touch(pluginMarkerFilePath); - } - } - private void deleteExistingPluginMarkerFiles(FileAccess fileAccess, ToolPluginDescriptor plugin, Path currentMarkerFilePath) { - - String markerFilePrefix = "plugin" + "." + getName() + "." + getInstalledEdition() + "." + plugin.name(); - List markerFiles = fileAccess.listChildren(currentMarkerFilePath.getParent(), - p -> { - String fileName = p.getFileName().toString(); - return Files.isRegularFile(p) && (fileName.equals(markerFilePrefix) || fileName.startsWith(markerFilePrefix + ".version-")); - }); - for (Path markerFile : markerFiles) { - if (!markerFile.equals(currentMarkerFilePath)) { - fileAccess.delete(markerFile); - LOG.debug("Deleted stale plugin marker file {} before creating {}.", markerFile, currentMarkerFilePath); - } - } + this.pluginManager.createPluginMarkerFile(plugin); } - /** - * @param plugin the {@link ToolPluginDescriptor} to install. - * @param step the {@link Step} for the plugin installation. - * @param pc the {@link ProcessContext} to use. - * @return boolean true if the installation of the plugin succeeded, false if not. - */ - public abstract boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessContext pc); + @Override + public Path getPluginsInstallationPath() { - /** - * @param plugin the {@link ToolPluginDescriptor} to install. - * @param step the {@link Step} for the plugin installation. - */ + return this.context.getPluginsPath().resolve(this.tool); + } + + @Override public void installPlugin(ToolPluginDescriptor plugin, final Step step) { + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.THROW_CLI); ToolInstallRequest request = new ToolInstallRequest(true); - request.setProcessContext(pc); install(request); installPlugin(plugin, step, pc); } - /** - * @param plugin the {@link ToolPluginDescriptor} to uninstall. - */ + @Override public void uninstallPlugin(ToolPluginDescriptor plugin) { - boolean error = false; - Path pluginsPath = getPluginsInstallationPath(); - if (!Files.isDirectory(pluginsPath)) { - LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not exist at {}", - plugin.name(), plugin.id(), pluginsPath); - error = true; - } - FileAccess fileAccess = this.context.getFileAccess(); - Path match = fileAccess.findFirst(pluginsPath, p -> p.getFileName().toString().startsWith(plugin.id()), false); - if (match == null) { - LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not contain a match at {}", - plugin.name(), plugin.id(), pluginsPath); - error = true; - } - if (error) { - LOG.error("Could not uninstall plugin {} because we could not find an installation", plugin); - } else { - fileAccess.delete(match); - LOG.info("Successfully uninstalled plugin {}", plugin); - } + this.pluginManager.uninstallPlugin(plugin); } - /** - * @param key the filename of the properties file configuring the requested plugin (typically excluding the ".properties" extension). - * @return the {@link ToolPluginDescriptor} for the given {@code key}. - */ - public ToolPluginDescriptor getPlugin(String key) { + @Override + public void deleteAllPlugins() { - if (key == null) { - return null; - } - if (key.endsWith(IdeContext.EXT_PROPERTIES)) { - key = key.substring(0, key.length() - IdeContext.EXT_PROPERTIES.length()); - } + this.context.getFileAccess().delete(getPluginsInstallationPath()); + } - ToolPlugins toolPlugins = getPlugins(); - ToolPluginDescriptor pluginDescriptor = toolPlugins.getByName(key); - if (pluginDescriptor == null) { - throw new CliException( - "Could not find plugin " + key + " at " + getPluginsConfigPath().resolve(key) + ".properties"); - } - return pluginDescriptor; + @Override + public ToolPluginDescriptor getPlugin(String key) { + + return this.pluginManager.getPlugin(key); } - /** - * @param plugin the in{@link ToolPluginDescriptor#active() active} {@link ToolPluginDescriptor} that is skipped for regular plugin installation. - */ - protected void handleInstallForInactivePlugin(ToolPluginDescriptor plugin) { + @Override + public void handleInstallForInactivePlugin(ToolPluginDescriptor plugin) { LOG.debug("Omitting installation of inactive plugin {} ({}).", plugin.name(), plugin.id()); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginFeatures.java b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginFeatures.java new file mode 100644 index 0000000000..13e588fbcd --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginFeatures.java @@ -0,0 +1,113 @@ +package com.devonfw.tools.ide.tool.plugin; + +import java.nio.file.Path; +import java.util.Collection; + +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.ToolCommandlet; + +/** + * Interface for tools that support plugin management. + *

+ * This follows the same pattern as {@link com.devonfw.tools.ide.tool.ide.IdeFeatures}, decoupling plugin capabilities from the installation mechanism. Both + * binary-installed IDEs (VS Code, IntelliJ) and package-manager-installed tools (Spyder via pip) can support plugins by composing a {@link PluginManager}. + *

+ */ +public interface PluginFeatures { + + /** + * @return the {@link ToolCommandlet#getName() name} of the tool owning the plugins. + */ + String getName(); + + /** + * @return the {@link ToolCommandlet#getConfiguredEdition() configured edition} of the tool owning the plugins. + */ + String getConfiguredEdition(); + + /** + * @return the {@link ToolCommandlet#getInstalledEdition() installed edition} of the tool owning the plugins or {@code null} if not installed. + */ + String getInstalledEdition(); + + /** + * @return the {@link Path} to the folder with the plugin configuration files inside the settings. + */ + Path getPluginsConfigPath(); + + /** + * @return the {@link Path} where the plugins of this tool shall be installed. + */ + Path getPluginsInstallationPath(); + + /** + * @return {@code true} if the {@link ToolPluginDescriptor#url() plugin url} is needed, {@code false} otherwise. + */ + boolean isPluginUrlNeeded(); + + /** + * @return the {@link ToolPlugins} configured for this tool. + */ + ToolPlugins getPlugins(); + + /** + * @param key the filename of the properties file configuring the requested plugin (typically excluding the ".properties" extension). + * @return the {@link ToolPluginDescriptor} for the given {@code key}. + */ + ToolPluginDescriptor getPlugin(String key); + + /** + * Installs the given active plugins and handles the inactive ones. + * + * @param plugins the {@link Collection} of {@link ToolPluginDescriptor plugins} to install. + * @param pc the {@link ProcessContext} to use. + */ + void installPlugins(Collection plugins, ProcessContext pc); + + /** + * Performs the tool-specific installation of a single plugin. + * + * @param plugin the {@link ToolPluginDescriptor} to install. + * @param step the {@link Step} for the plugin installation. + * @param pc the {@link ProcessContext} to use. + * @return {@code true} if the installation of the plugin succeeded, {@code false} if not. + */ + boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessContext pc); + + /** + * Ensures that the tool itself is installed and then installs the plugin. + * + * @param plugin the {@link ToolPluginDescriptor} to install. + * @param step the {@link Step} for the plugin installation. + */ + void installPlugin(ToolPluginDescriptor plugin, final Step step); + + /** + * @param plugin the {@link ToolPluginDescriptor} to uninstall. + */ + void uninstallPlugin(ToolPluginDescriptor plugin); + + /** + * Uninstalls all currently installed plugins so that they can be installed again as configured in the project settings. + */ + void deleteAllPlugins(); + + /** + * @param plugin the in {@link ToolPluginDescriptor#active() active} {@link ToolPluginDescriptor} that is skipped for regular plugin installation. + */ + void handleInstallForInactivePlugin(ToolPluginDescriptor plugin); + + /** + * @param plugin the {@link ToolPluginDescriptor plugin} to search for. + * @return the {@link Path} to the plugin marker file or {@code null} if we are not inside an IDEasy project. + */ + Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin); + + /** + * Creates a marker file for a plugin in {@code $IDE_HOME/.ide/plugin.<>.<>.<>}. + * + * @param plugin the plugin the {@link ToolPluginDescriptor plugin} for which the marker file should be created. + */ + void createPluginMarkerFile(ToolPluginDescriptor plugin); +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java new file mode 100644 index 0000000000..5f123026d8 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java @@ -0,0 +1,303 @@ +package com.devonfw.tools.ide.tool.plugin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.environment.VariableLine; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.step.Step; + +/** + * Manages plugin configuration and common plugin operations for tools that support plugins. + */ +public class PluginManager { + + private static final Logger LOG = LoggerFactory.getLogger(PluginManager.class); + + /** The prefix of a plugin marker file inside {@link IdeContext#FOLDER_DOT_IDE .ide} Folder. */ + public static final String MARKER_FILE_PREFIX = "plugin."; + + /** The infix of a plugin marker file separating the plugin name from its version. */ + public static final String MARKER_FILE_VERSION_INFIX = ".version-"; + + private final IdeContext context; + + private final PluginFeatures tool; + + private ToolPlugins plugins; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param tool the {@link PluginFeatures tool} owning the plugins managed by this {@link PluginManager}. + */ + public PluginManager(IdeContext context, PluginFeatures tool) { + super(); + this.context = context; + this.tool = tool; + } + + + /** + * @return the {@link ToolPlugins} of this {@link PluginBasedCommandlet}. + */ + public ToolPlugins getPlugins() { + + if (this.plugins == null) { + ToolPlugins toolPlugins = new ToolPlugins(); + + // Load project-specific plugins + Path pluginsPath = this.tool.getPluginsConfigPath(); + loadPluginsFromDirectory(toolPlugins, pluginsPath); + + // Load user-specific plugins, this is done after loading the project-specific plugins so the user can potentially + // override plugins (e.g. change active flag). + Path userPluginsPath = getUserHomePluginConfigPath(); + loadPluginsFromDirectory(toolPlugins, userPluginsPath); + + this.plugins = toolPlugins; + } + + return this.plugins; + } + + + private void loadPluginsFromDirectory(ToolPlugins map, Path pluginsPath) { + + List children = this.context.getFileAccess() + .listChildren(pluginsPath, p -> p.getFileName().toString().endsWith(IdeContext.EXT_PROPERTIES)); + for (Path child : children) { + ToolPluginDescriptor descriptor = ToolPluginDescriptor.of(child, this.context, this.tool.isPluginUrlNeeded()); + map.add(descriptor); + } + } + + private Path getUserHomePluginConfigPath() { + return this.context.getUserHomeIde().resolve(IdeContext.FOLDER_SETTINGS).resolve(this.tool.getName()).resolve(IdeContext.FOLDER_PLUGINS); + } + + /** + * @param key the filename of the properties file configuring the requested plugin (typically excluding the ".properties" extension). + * @return the {@link ToolPluginDescriptor} for the given {@code key}. + */ + public ToolPluginDescriptor getPlugin(String key) { + + if (key == null) { + return null; + } + if (key.endsWith(IdeContext.EXT_PROPERTIES)) { + key = key.substring(0, key.length() - IdeContext.EXT_PROPERTIES.length()); + } + + ToolPlugins toolPlugins = getPlugins(); + ToolPluginDescriptor pluginDescriptor = toolPlugins.getByName(key); + if (pluginDescriptor == null) { + throw new CliException( + "Could not find plugin " + key + " at " + this.tool.getPluginsConfigPath().resolve(key) + ".properties"); + } + return pluginDescriptor; + } + + /** + * Reset all installed plugins by deleting the {@link PluginFeatures#getPluginsInstallationPath() plugins installation folder} and all plugin marker files. + */ + public void resetPlugins() { + LOG.info("Resetting all installed plugins..."); + this.tool.deleteAllPlugins(); + deleteAllPluginMarkerFiles(); + } + + /** + * Deletes all plugin marker files the {@link PluginFeatures tool} so that its plugins will be installed again. + */ + public void deleteAllPluginMarkerFiles() { + FileAccess fileAccess = this.context.getFileAccess(); + List markerFiles = fileAccess.listChildren(this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE), Files::isRegularFile); + for (Path path : markerFiles) { + if (path.getFileName().toString().startsWith(MARKER_FILE_PREFIX + this.tool.getName())) { + fileAccess.delete(path); + LOG.debug("Plugin marker file {} got deleted.", path); + } + } + } + + /** + * Installs the given plugins, activating those explicitly requested via the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable in addition to the + * {@link ToolPluginDescriptor#active() active} ones, and handles the inactive plugins that are not explicitly requested. + * + * @param plugins as {@link Collection} of plugins to install. + * @param pc the {@link ProcessContext} to use. + */ + public void installPlugins(Collection plugins, ProcessContext pc) { + + Set extraPlugins = getExtraPlugins(plugins); + String edition = this.tool.getConfiguredEdition(); + List pluginsToInstall = new ArrayList<>(plugins.size()); + for (ToolPluginDescriptor plugin : plugins) { + if (plugin.excludedEditions().contains(edition)) { + LOG.debug("Skipping plugin '{}' (excluded for edition '{}').", plugin.name(), edition); + } else if (plugin.active() || extraPlugins.contains(plugin.name())) { + pluginsToInstall.add(plugin); + } else { + Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); + if ((pluginMarkerFile == null) || !Files.exists(pluginMarkerFile)) { + this.tool.handleInstallForInactivePlugin(plugin); + } + } + } + int currentPluginIndex = 1; + int totalPlugins = pluginsToInstall.size(); + for (ToolPluginDescriptor plugin : pluginsToInstall) { + Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); + boolean pluginMarkerFileExists = (pluginMarkerFile != null) && Files.exists(pluginMarkerFile); + if (pluginMarkerFileExists) { + LOG.debug("Markerfile for IDE {} and plugin '{}' already exists.", this.tool.getName(), plugin.name()); + } + if (this.context.isForcePlugins() || !pluginMarkerFileExists) { + String progressMarker = " (" + currentPluginIndex + "/" + totalPlugins + ")"; + Step step = this.context.newStep("Install plugin " + plugin.name() + progressMarker); + step.run(() -> doInstallPluginStep(plugin, step, pc)); + } else { + LOG.debug("Skipping installation of plugin '{}' due to existing marker file: {}", plugin.name(), pluginMarkerFile); + } + currentPluginIndex++; + } + } + + /** + * @param plugins the configured {@link ToolPluginDescriptor plugins} used to detect undefined entries. + * @return the {@link Set} of {@link ToolPluginDescriptor#name() plugin names} configured in the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable (e.g. + * {@code VSCODE_EXTRA_PLUGINS=copilot,docker}). This allows a user to permanently opt-in to plugins that are not {@link ToolPluginDescriptor#active() + * active} in the project settings, without modifying the shared settings and without losing them when plugins are purged and reinstalled on IDE upgrade. + * Values refer to the {@link ToolPluginDescriptor#name() name} of the plugin (the filename of its {@code .properties} file) and not to the + * {@link ToolPluginDescriptor#id() id}. Names that do not resolve to a configured plugin are logged as a warning and skipped so that a single stale entry + * cannot break the entire installation. + */ + public Set getExtraPlugins(Collection plugins) { + + String variable = EnvironmentVariables.getToolExtraPluginsVariable(this.tool.getName()); + String value = this.context.getVariables().get(variable); + if ((value == null) || value.isBlank()) { + return Set.of(); + } + Set extraPlugins = new LinkedHashSet<>(); + for (String name : VariableLine.parseArray(value)) { + if (name.endsWith(IdeContext.EXT_PROPERTIES)) { + name = name.substring(0, name.length() - IdeContext.EXT_PROPERTIES.length()); + } + extraPlugins.add(name); + } + Set undefinedPlugins = new LinkedHashSet<>(extraPlugins); + for (ToolPluginDescriptor plugin : plugins) { + undefinedPlugins.remove(plugin.name()); + } + for (String name : undefinedPlugins) { + LOG.info("Ignoring undefined plugin '{}' configured in variable {} - no file {}{} found in {} or {}.", name, variable, name, IdeContext.EXT_PROPERTIES, + this.tool.getPluginsConfigPath(), getUserHomePluginConfigPath()); + } + return extraPlugins; + } + + private void doInstallPluginStep(ToolPluginDescriptor plugin, Step step, ProcessContext pc) { + + boolean result = this.tool.installPlugin(plugin, step, pc); + if (result) { + createPluginMarkerFile(plugin); + } + } + + /** + * @param plugin the {@link ToolPluginDescriptor plugin} to search for. + * @return Path to the plugin marker file. + */ + public Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin) { + if (this.context.getIdeHome() != null) { + String markerFileName = getMarkerFilePrefix(plugin); + String version = plugin.version(); + if ((version != null) && !version.isBlank()) { + markerFileName = markerFileName + ".version-" + normalizeMarkerFileSegment(version); + } + return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(markerFileName); + } + return null; + } + + private String getMarkerFilePrefix(ToolPluginDescriptor plugin) { + return MARKER_FILE_PREFIX + this.tool.getName() + "." + this.tool.getInstalledEdition() + "." + plugin.name(); + } + + private String normalizeMarkerFileSegment(String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + /** + * Creates a marker file for a plugin in $IDE_HOME/.ide/plugin.«ide».«plugin-name» + * + * @param plugin the {@link ToolPluginDescriptor plugin} for which the marker file should be created. + */ + public void createPluginMarkerFile(ToolPluginDescriptor plugin) { + Path pluginMarkerFilePath = retrievePluginMarkerFilePath(plugin); + if (pluginMarkerFilePath != null) { + FileAccess fileAccess = this.context.getFileAccess(); + fileAccess.mkdirs(pluginMarkerFilePath.getParent()); + deleteExistingPluginMarkerFiles(fileAccess, plugin, pluginMarkerFilePath); + fileAccess.touch(pluginMarkerFilePath); + } + } + + private void deleteExistingPluginMarkerFiles(FileAccess fileAccess, ToolPluginDescriptor plugin, Path currentMarkerFilePath) { + + String markerFilePrefix = getMarkerFilePrefix(plugin); + List markerFiles = fileAccess.listChildren(currentMarkerFilePath.getParent(), + p -> { + String fileName = p.getFileName().toString(); + return Files.isRegularFile(p) && (fileName.equals(markerFilePrefix) || fileName.startsWith(markerFilePrefix + MARKER_FILE_VERSION_INFIX)); + }); + for (Path markerFile : markerFiles) { + if (!markerFile.equals(currentMarkerFilePath)) { + fileAccess.delete(markerFile); + LOG.debug("Deleted stale plugin marker file {} before creating {}.", markerFile, currentMarkerFilePath); + } + } + } + + /** + * @param plugin the {@link ToolPluginDescriptor} to uninstall. + */ + public void uninstallPlugin(ToolPluginDescriptor plugin) { + + boolean error = false; + Path pluginsPath = this.tool.getPluginsInstallationPath(); + if (!Files.isDirectory(pluginsPath)) { + LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not exist at {}", + plugin.name(), plugin.id(), pluginsPath); + error = true; + } + FileAccess fileAccess = this.context.getFileAccess(); + Path match = fileAccess.findFirst(pluginsPath, p -> p.getFileName().toString().startsWith(plugin.id()), false); + if (match == null) { + LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not contain a match at {}", + plugin.name(), plugin.id(), pluginsPath); + error = true; + } + if (error) { + LOG.error("Could not uninstall plugin {} because we could not find an installation", plugin); + } else { + fileAccess.delete(match); + LOG.info("Successfully uninstalled plugin {}", plugin); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java b/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java index 2a27df1c72..51559b6582 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java @@ -1,10 +1,14 @@ package com.devonfw.tools.ide.tool.spyder; +import java.nio.file.Path; +import java.util.List; import java.util.Set; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.pip.PipBasedIdeToolCommandlet; @@ -13,8 +17,11 @@ */ public class Spyder extends PipBasedIdeToolCommandlet { - /** Environment variable that tells Spyder to use an IDEasy-managed config directory instead of the shared user config. */ - private static final String SPYDER_CONFIG_DIR = "SPYDER_CONFIG_DIR"; + /** The name of the Spyder config folder (defaults to .spyder-py3 in user home). */ + private static final String SPYDER_CONFDIR_NAME = ".spyder-py3"; + + /** Environment variable to override Spyder's default config directory. */ + private static final String SPYDER_CONFDIR = "SPYDER_CONFDIR"; /** * The constructor. @@ -29,9 +36,17 @@ public Spyder(IdeContext context) { public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { super.setEnvironment(environmentContext, toolInstallation, additionalInstallation); - // Point Spyder to an IDEasy-managed config directory so its settings stay isolated per IDE_HOME. - if (this.context.getConfPath() != null) { - environmentContext.withEnvVar(SPYDER_CONFIG_DIR, this.context.getConfPath().resolve("spyder").toString()); + Path spyderConfig = this.context.getWorkspacePath().resolve(SPYDER_CONFDIR_NAME); + environmentContext.withEnvVar(SPYDER_CONFDIR, spyderConfig.toString()); + } + + @Override + protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { + Path workspacePath = this.context.getWorkspacePath(); + if (workspacePath != null) { + pc.addArg("--project"); + pc.addArg(workspacePath.toString()); } + super.configureToolArgs(pc, processMode, args); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextTest.java b/cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextTest.java new file mode 100644 index 0000000000..3a842c97a6 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextTest.java @@ -0,0 +1,38 @@ +package com.devonfw.tools.ide.context; + +import java.util.ArrayList; +import java.util.List; + +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessContextImpl; + +/** + * Mock {@link ProcessContext} that captures executed commands for testing without actually running them. + */ +public class CapturingProcessContextTest extends ProcessContextImpl { + + private final List capturedArgs = new ArrayList<>(); + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public CapturingProcessContextTest(IdeContext context) { + super(context); + } + + @Override + public ProcessContext addArg(String arg) { + this.capturedArgs.add(arg); + return this; + } + + /** + * @return the {@link capturedArgs captured arguments}. + */ + public List getArgs() { + + return this.capturedArgs; + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java new file mode 100644 index 0000000000..77b96bf10b --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java @@ -0,0 +1,52 @@ +package com.devonfw.tools.ide.tool.pip; + +import java.util.List; +import java.util.Set; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.process.ProcessResultImpl; +import com.devonfw.tools.ide.tool.ToolInstallRequest; + +/** + * Test double of {@link PipBasedIdeToolCommandlet} that records the workspace configuration and short-circuits the actual installation and process launch for + * testing. + */ +public class ExamplePipBasedIdeToolCommandlet extends PipBasedIdeToolCommandlet { + + private boolean workspaceConfigured; + + /** + * The constructor. + * + * @param context the {@link IdeTestContext}. + * @param tool the {@link #getName() tool name}. + * @param tags the {@link #getTags() tags} classifying the tool. + */ + public ExamplePipBasedIdeToolCommandlet(IdeTestContext context, String tool, Set tags) { + + super(context, tool, tags); + } + + @Override + public void configureWorkspace() { + // only record the call, do not perform the real workspace configuration + this.workspaceConfigured = true; + } + + @Override + public ProcessResult runTool(ToolInstallRequest request, ProcessMode processMode, List args) { + // skip the actual installation and process launch + return new ProcessResultImpl(getName(), getName(), ProcessResult.SUCCESS, List.of()); + } + + /** + * @return {@code true} if {@link #configureWorkspace()} has been triggered, {@code false} otherwise. + */ + public boolean wasWorkspaceConfigured() { + + return this.workspaceConfigured; + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java new file mode 100644 index 0000000000..15ea5f8604 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java @@ -0,0 +1,86 @@ +package com.devonfw.tools.ide.tool.pip; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.context.ProcessContextTestImpl; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; +import com.devonfw.tools.ide.tool.python.Python; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +/** + * Test of {@link PipBasedIdeToolCommandlet}. + */ +@WireMockTest +class PipBasedIdeToolCommandletTest extends AbstractIdeContextTest { + + private static final String PROJECT_PIP = "pip"; + + /** The (dummy) name of the tool managed by the test commandlet. */ + private static final String TOOL = "mockedide"; + + /** + * Tests that the plugin installation path of a pip-based IDE points at the shared python environment (the parent tool) that holds the IDE itself. + */ + @Test + void testPluginsInstallationPathIsSharedPythonEnvironment() { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP); + ExamplePipBasedIdeToolCommandlet commandlet = new ExamplePipBasedIdeToolCommandlet(context, TOOL, Set.of(Tag.PYTHON)); + Python python = context.getCommandletManager().getCommandlet(Python.class); + + // assert + assertThat(commandlet.getPluginsInstallationPath()).isEqualTo(python.getToolPath()); + assertThat(commandlet.getPluginsInstallationPath()).isEqualTo(commandlet.getToolPath()); + } + + /** + * Tests that installing a plugin of a pip-based IDE is delegated to the package manager (pip): the plugin's python package id is used and the installation is + * reported as successful. + * + * @param wireMockRuntimeInfo wireMock server on a random port providing the mocked PyPI index. + */ + @Test + void testInstallPluginIsDelegatedToPackageManager(WireMockRuntimeInfo wireMockRuntimeInfo) { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP, wireMockRuntimeInfo); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + ExamplePipBasedIdeToolCommandlet commandlet = new ExamplePipBasedIdeToolCommandlet(context, TOOL, Set.of(Tag.PYTHON)); + ToolPluginDescriptor plugin = new ToolPluginDescriptor("testplugin", "TestPlugin", null, "1.0.0", true, null, null); + Step step = context.newStep("Install plugin TestPlugin"); + + // act + step.run(() -> commandlet.installPlugin(plugin, step, new ProcessContextTestImpl(context))); + + // assert + assertThat(context).logAtSuccess().hasMessage("Successfully installed plugin: TestPlugin"); + } + + /** + * Tests that {@link PipBasedIdeToolCommandlet#runTool(List)} (the entry point of {@code ide run «tool»}) triggers + * {@link PipBasedIdeToolCommandlet#configureWorkspace()}. + */ + @Test + void testRunToolTriggersWorkspaceConfiguration() { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP); + ExamplePipBasedIdeToolCommandlet commandlet = new ExamplePipBasedIdeToolCommandlet(context, TOOL, Set.of(Tag.PYTHON)); + + // act + commandlet.runTool(List.of()); + + // assert - runTool triggered the workspace configuration as part of the run flow + assertThat(commandlet.wasWorkspaceConfigured()).isTrue(); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java index 1c9326d282..0afc14343c 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java @@ -1,12 +1,15 @@ package com.devonfw.tools.ide.tool.spyder; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.CapturingProcessContextTest; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; import com.devonfw.tools.ide.tool.pip.PipBasedCommandlet; @@ -62,8 +65,11 @@ void testSpyderIsPipBasedIdeToolCommandlet(WireMockRuntimeInfo wireMockRuntimeIn assertThat(commandlet).isInstanceOf(PipBasedIdeToolCommandlet.class); } + /** + * Tests that {@link Spyder#setEnvironment} points SPYDER_CONFDIR to the workspace-specific config directory. + */ @Test - void testSpyderSetEnvironmentUsesIsolatedConfigDir() { + void testSpyderSetEnvironmentUsesWorkspaceConfigDir() { // arrange IdeTestContext context = newContext(PROJECT_PIP); @@ -75,7 +81,25 @@ void testSpyderSetEnvironmentUsesIsolatedConfigDir() { // act commandlet.setEnvironment(environmentContext, installation, false); - // assert - assertThat(environmentContext.set).containsEntry("SPYDER_CONFIG_DIR", context.getConfPath().resolve("spyder").toString()); + // assert — SPYDER_CONFDIR points to workspace/.spyder-py3 + assertThat(environmentContext.set).containsEntry("SPYDER_CONFDIR", context.getWorkspacePath().resolve(".spyder-py3").toString()); + } + + /** + * Tests that {@link Spyder#configureToolArgs} points the IDE at the current workspace by adding the {@code --project} argument. + */ + @Test + void testSpyderConfigureToolArgsAddsProjectArg() { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP); + Spyder commandlet = new Spyder(context); + CapturingProcessContextTest pc = new CapturingProcessContextTest(context); + + // act + commandlet.configureToolArgs(pc, ProcessMode.DEFAULT, List.of()); + + // assert — spyder is started with --project pointing to the workspace + assertThat(pc.getArgs()).containsExactly("--project", context.getWorkspacePath().toString()); } }