From 876237605a777bb08b5ab1f5ed94a8e4e1cb5c44 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Thu, 6 Aug 2026 09:55:04 +0200 Subject: [PATCH 1/5] #989: add expression function framework for template variables --- CHANGELOG.adoc | 1 + .../tools/ide/context/AbstractIdeContext.java | 34 ++ .../devonfw/tools/ide/context/IdeContext.java | 20 ++ .../tools/ide/context/IdeContextConsole.java | 15 + .../ide/expression/ExpressionContext.java | 47 +++ .../ide/expression/ExpressionFunction.java | 39 ++ .../expression/ExpressionFunctionManager.java | 72 ++++ .../ide/expression/ExpressionParser.java | 178 +++++++++ .../ide/expression/function/AskFunction.java | 113 ++++++ .../ide/expression/function/IfOsFunction.java | 68 ++++ .../ide/expression/function/PathFunction.java | 59 +++ .../ide/expression/ExpressionParserTest.java | 339 ++++++++++++++++++ 12 files changed, 985 insertions(+) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionContext.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunction.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunctionManager.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/function/IfOsFunction.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/expression/function/PathFunction.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 5932a42dce..e66587c88d 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions * https://github.com/devonfw/IDEasy/issues/2187[#2187]: Start SoapUI commandlet in background * https://github.com/devonfw/IDEasy/issues/2189[#2189]: Integrate Ruff * https://github.com/devonfw/IDEasy/issues/2126[#2126]: Fix language selection dropdown diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 5c98d10b04..39c0b76aae 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -1079,6 +1079,31 @@ public String askForInput(String message, String defaultValue) { } } + @Override + public String askForSecret(String message, String defaultValue) { + + while (true) { + if (!message.isBlank()) { + IdeLogLevel.INTERACTION.log(LOG, message); + } + if (isBatchMode()) { + if (isForceMode()) { + return defaultValue; + } else { + throw new CliAbortException(); + } + } + String input = readSecretLine().trim(); + if (!input.isEmpty()) { + return input; + } else { + if (defaultValue != null) { + return defaultValue; + } + } + } + } + @Override public O question(O[] options, String question, Object... args) { @@ -1147,6 +1172,15 @@ private static String computeOptionKey(String option) { */ protected abstract String readLine(); + /** + * @return the secret input from the end-user (e.g. read from the console without echoing it). The default implementation simply delegates to + * {@link #readLine()} so that sub-classes without a secure console (e.g. in tests) work out of the box. + */ + protected String readSecretLine() { + + return readLine(); + } + private static void addMapping(Map mapping, String key, O option) { O duplicate = mapping.put(key, option); diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index 3443b60d68..217aac2e64 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -272,6 +272,26 @@ default String askForInput(String message) { return askForInput(message, null); } + /** + * Asks the user for a single secret input (e.g. a password or API token). Unlike {@link #askForInput(String, String)} the input is not echoed to the console + * if a secure console is available. + * + * @param message The information message to display. + * @param defaultValue The default value to return when no input is provided or {@code null} to keep asking until the user entered a non empty value. + * @return The secret input from the user, or the default value if no input is provided. + */ + String askForSecret(String message, String defaultValue); + + /** + * Asks the user for a single secret input (e.g. a password or API token). + * + * @param message The information message to display. + * @return The secret input from the user. + */ + default String askForSecret(String message) { + return askForSecret(message, null); + } + /** * @param question the question to ask. * @param args arguments for filling the templates diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContextConsole.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContextConsole.java index 81e949ea8f..4540952727 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContextConsole.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContextConsole.java @@ -52,6 +52,21 @@ protected String readLine() { } } + @Override + protected String readSecretLine() { + + if (this.scanner == null) { + char[] password = System.console().readPassword(); + if (password == null) { + return ""; + } + return new String(password); + } else { + LOG.warn("System console not available - secret input will be visible while typing."); + return this.scanner.nextLine(); + } + } + @Override public IdeProgressBar newProgressBar(String title, long size, String unitName, long unitSize) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionContext.java b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionContext.java new file mode 100644 index 0000000000..d0c3455cc0 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionContext.java @@ -0,0 +1,47 @@ +package com.devonfw.tools.ide.expression; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * Interface for the context available to an {@link ExpressionFunction} while an expression is evaluated. + */ +public interface ExpressionContext { + + /** + * @return the {@link IdeContext}. + */ + IdeContext getIdeContext(); + + /** + * Resolves variables in the given value. Used to resolve arguments of a function that may themselves contain + * variables or nested expressions (e.g. {@code @path('$[IDE_HOME]/software/node')}). + * + * @param value the value to resolve. + * @return the given value with variables and nested expressions resolved. + */ + String resolve(String value); + + /** + * @param name the name of the variable. + * @return the value of the variable or {@code null} if not defined in any level of the hierarchy. + */ + String getVariable(String name); + + /** + * Persists the given variable to the user local {@code conf/ide.properties} so the user is not asked again. + *

+ * Only has an effect if {@link #isPersistent()} returns {@code true}. + * + * @param name the name of the variable. + * @param value the value to persist. + */ + void setVariable(String name, String value); + + /** + * @return {@code true} if values acquired from the user should be {@link #setVariable(String, String) persisted}. + * This is the case for workspace templates that are re-applied on every {@code ide update}. For settings + * templates that are only instantiated once, this is {@code false}. + */ + boolean isPersistent(); + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunction.java b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunction.java new file mode 100644 index 0000000000..e43e7ea267 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunction.java @@ -0,0 +1,39 @@ +package com.devonfw.tools.ide.expression; + +import java.util.List; + +/** + * Interface for a function that can be used in an expression of a template variable definition. + *

+ * The syntax of an expression is {@code @«function-name»([«arg»[,«arg»]*])}. Implementations are registered in the + * {@link ExpressionFunctionManager}. + * + * @see ExpressionFunctionManager + */ +public interface ExpressionFunction { + + /** + * @return the name of this function as used in the expression syntax (e.g. "path" for {@code @path(...)}). Has to match + * {@code [a-z][a-z0-9-]*}. + */ + String getName(); + + /** + * @return the minimum number of arguments required by this function. + */ + int getMinArgs(); + + /** + * @return the maximum number of arguments supported by this function or {@code -1} for an unlimited number. + */ + int getMaxArgs(); + + /** + * @param args the {@link List} of arguments. Already trimmed, unquoted and with variables resolved. Guaranteed to + * satisfy {@link #getMinArgs()} and {@link #getMaxArgs()}. + * @param context the {@link ExpressionContext}. + * @return the result of this function that will replace the entire expression. + */ + String apply(List args, ExpressionContext context); + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunctionManager.java b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunctionManager.java new file mode 100644 index 0000000000..4a88c8c00f --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionFunctionManager.java @@ -0,0 +1,72 @@ +package com.devonfw.tools.ide.expression; + +import java.util.HashMap; +import java.util.Map; + +import com.devonfw.tools.ide.expression.function.AskFunction; +import com.devonfw.tools.ide.expression.function.IfOsFunction; +import com.devonfw.tools.ide.expression.function.PathFunction; + +/** + * Manager where all {@link ExpressionFunction}s are registered so they can be looked up by + * {@link #getFunction(String) name} while an expression is resolved. + *

+ * With new IDEasy releases additional functions can simply be registered here. + */ +public class ExpressionFunctionManager { + + private static final ExpressionFunctionManager DEFAULT = createDefault(); + + private final Map functions; + + /** + * The constructor. + */ + public ExpressionFunctionManager() { + + super(); + this.functions = new HashMap<>(); + } + + /** + * @param function the {@link ExpressionFunction} to register. + */ + public void register(ExpressionFunction function) { + + ExpressionFunction duplicate = this.functions.put(function.getName(), function); + if (duplicate != null) { + throw new IllegalStateException("Duplicate expression function @" + function.getName()); + } + } + + /** + * @param name the {@link ExpressionFunction#getName() name} of the requested function. + * @return the {@link ExpressionFunction} or {@code null} if no function is registered for the given name. A + * {@code null} result is not an error: the expression is then left untouched. + */ + public ExpressionFunction getFunction(String name) { + + return this.functions.get(name); + } + + /** + * @return the default instance with all standard functions registered. + */ + public static ExpressionFunctionManager get() { + + return DEFAULT; + } + + private static ExpressionFunctionManager createDefault() { + + ExpressionFunctionManager manager = new ExpressionFunctionManager(); + manager.register(new PathFunction()); + manager.register(AskFunction.ofVariable()); + manager.register(AskFunction.ofSecret()); + for (IfOsFunction function : IfOsFunction.all()) { + manager.register(function); + } + return manager; + } + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java new file mode 100644 index 0000000000..ab63bbef07 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java @@ -0,0 +1,178 @@ +package com.devonfw.tools.ide.expression; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parser for expressions of the syntax {@code @«function-name»([«arg»[,«arg»]*])}. + *

+ * A regular expression is only used to locate the start of a function call. The argument list is then scanned + * manually, because a regular expression cannot express a balanced list of an arbitrary number of arguments that may + * contain quoted commas, quoted parenthesis or nested function calls. + *

+ * Text that does not form a call of a {@link ExpressionFunctionManager#getFunction(String) registered function} is + * passed through entirely untouched. This is essential since foreign configuration formats may use an {@code @} for + * their own purposes (e.g. CSS {@code @media(...)}) and IDEasy must never try to resolve placeholders that are not + * ours. + */ +public class ExpressionParser { + + private static final Logger LOG = LoggerFactory.getLogger(ExpressionParser.class); + + /** Locates the start of a potential function call. The group is the function name. */ + // .1 + private static final Pattern FUNCTION_START = Pattern.compile("@([a-z][a-z0-9-]*)\\("); + + private static final int EXTRA_CAPACITY = 8; + + private final ExpressionFunctionManager functionManager; + + /** + * The constructor. + * + * @param functionManager the {@link ExpressionFunctionManager}. + */ + public ExpressionParser(ExpressionFunctionManager functionManager) { + + super(); + this.functionManager = functionManager; + } + + /** + * @param value the value potentially containing expressions. + * @param context the {@link ExpressionContext}. + * @return the given value with all expressions of registered functions replaced by their result. + */ + public String resolve(String value, ExpressionContext context) { + + if (value == null) { + return null; + } + Matcher matcher = FUNCTION_START.matcher(value); + if (!matcher.find()) { + return value; + } + StringBuilder sb = new StringBuilder(value.length() + EXTRA_CAPACITY); + int pos = 0; + while (matcher.find(pos)) { + int start = matcher.start(); + int open = matcher.end() - 1; + String functionName = matcher.group(1); + int close = findClosingParenthesis(value, open); + ExpressionFunction function = (close < 0) ? null : this.functionManager.getFunction(functionName); + if (function == null) { + LOG.trace("Ignoring '@{}(' in '{}' as it is no registered expression function.", functionName, value); + sb.append(value, pos, matcher.end()); + pos = matcher.end(); + continue; + } + sb.append(value, pos, start); + List args = parseArguments(value, open + 1, close, context); + sb.append(apply(function, args, value, context)); + pos = close + 1; + } + sb.append(value, pos, value.length()); + return sb.toString(); + } + + private String apply(ExpressionFunction function, List args, String value, ExpressionContext context) { + + int size = args.size(); + int min = function.getMinArgs(); + int max = function.getMaxArgs(); + if ((size < min) || ((max >= 0) && (size > max))) { + throw new IllegalArgumentException( + "Function @" + function.getName() + " requires " + min + (max < 0 ? " or more" : " to " + max) + + " argument(s) but received " + size + " in '" + value + "'."); + } + String result = function.apply(args, context); + return (result == null) ? "" : result; + } + + /** + * @param value the value to scan. + * @param open the index of the opening parenthesis. + * @return the index of the matching closing parenthesis or {@code -1} if unbalanced. + */ + private static int findClosingParenthesis(String value, int open) { + + int depth = 0; + char quote = 0; + for (int i = open; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == quote) { + quote = 0; + } + } else if ((c == '\'') || (c == '"')) { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + + /** + * Splits the argument list at top-level commas, then trims, unquotes and resolves each argument. + * + * @param value the entire value. + * @param begin the index after the opening parenthesis. + * @param end the index of the closing parenthesis (exclusive). + * @param context the {@link ExpressionContext}. + * @return the {@link List} of arguments. + */ + private static List parseArguments(String value, int begin, int end, ExpressionContext context) { + + List args = new ArrayList<>(); + if (value.substring(begin, end).isBlank()) { + return args; + } + int depth = 0; + char quote = 0; + int argStart = begin; + for (int i = begin; i < end; i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == quote) { + quote = 0; + } + } else if ((c == '\'') || (c == '"')) { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if ((c == ',') && (depth == 0)) { + args.add(parseArgument(value.substring(argStart, i), context)); + argStart = i + 1; + } + } + args.add(parseArgument(value.substring(argStart, end), context)); + return args; + } + + private static String parseArgument(String arg, ExpressionContext context) { + + String result = arg.trim(); + int length = result.length(); + if (length >= 2) { + char first = result.charAt(0); + if (((first == '\'') || (first == '"')) && (result.charAt(length - 1) == first)) { + result = result.substring(1, length - 1); + } + } + return context.resolve(result); + } + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java b/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java new file mode 100644 index 0000000000..f026be184d --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java @@ -0,0 +1,113 @@ +package com.devonfw.tools.ide.expression.function; + +import java.util.List; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.expression.ExpressionContext; +import com.devonfw.tools.ide.expression.ExpressionFunction; + +/** + * {@link ExpressionFunction} {@code @ask-variable} that asks for a variable in plain text and {@code @ask-secret} that + * asks for a secret variable with masked input. + *

    + *
  1. the name of the requested variable. If the variable is already defined it is returned without asking. If the + * empty string is given, the user is always asked.
  2. + *
  3. optional: an explicit question used as prompt. If omitted, defaults to + * {@code Please enter the value for the (secret) variable «NAME»:}. If the 1st argument is empty, this argument is + * required.
  4. + *
  5. optional: a default value. Provide the empty string ({@code ''}) to allow empty input.
  6. + *
+ * Example: {@code @ask-secret('AI_API_KEY', 'Please enter your API key for the AI backend:')} + */ +public class AskFunction implements ExpressionFunction { + + private static final String NAME_VARIABLE = "ask-variable"; + + private static final String NAME_SECRET = "ask-secret"; + + private final String name; + + private final boolean secret; + + private AskFunction(String name, boolean secret) { + + super(); + this.name = name; + this.secret = secret; + } + + @Override + public String getName() { + + return this.name; + } + + @Override + public int getMinArgs() { + + return 1; + } + + @Override + public int getMaxArgs() { + + return 3; + } + + @Override + public String apply(List args, ExpressionContext context) { + + String variableName = args.get(0); + String question = (args.size() > 1) ? args.get(1) : null; + String defaultValue = (args.size() > 2) ? args.get(2) : null; + + if (variableName.isEmpty()) { + if ((question == null) || question.isEmpty()) { + throw new IllegalArgumentException( + "Function @" + this.name + " requires an explicit question as 2nd argument if the variable name is empty."); + } + return ask(question, defaultValue, context); + } + String value = context.getVariable(variableName); + if (value != null) { + return value; + } + if (question == null) { + question = "Please enter the value for the " + (this.secret ? "secret " : "") + "variable " + variableName + ":"; + } + value = ask(question, defaultValue, context); + if (context.isPersistent()) { + context.setVariable(variableName, value); + } + return value; + } + + private String ask(String question, String defaultValue, ExpressionContext context) { + + IdeContext ideContext = context.getIdeContext(); + String value; + if (this.secret) { + value = ideContext.askForSecret(question, defaultValue); + } else { + value = ideContext.askForInput(question, defaultValue); + } + return (value == null) ? "" : value; + } + + /** + * @return the {@link AskFunction} for {@code @ask-variable}. + */ + public static AskFunction ofVariable() { + + return new AskFunction(NAME_VARIABLE, false); + } + + /** + * @return the {@link AskFunction} for {@code @ask-secret}. + */ + public static AskFunction ofSecret() { + + return new AskFunction(NAME_SECRET, true); + } + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/function/IfOsFunction.java b/cli/src/main/java/com/devonfw/tools/ide/expression/function/IfOsFunction.java new file mode 100644 index 0000000000..f37595c240 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/function/IfOsFunction.java @@ -0,0 +1,68 @@ +package com.devonfw.tools.ide.expression.function; + +import java.util.List; +import java.util.function.Predicate; + +import com.devonfw.tools.ide.expression.ExpressionContext; +import com.devonfw.tools.ide.expression.ExpressionFunction; +import com.devonfw.tools.ide.os.SystemInfo; + +/** + * {@link ExpressionFunction} {@code @if-windows}, {@code @if-mac}, {@code @if-linux} and {@code @if-unix}. + *
    + *
  1. the text to insert if the operating system matches. Otherwise the expression resolves to the empty string.
  2. + *
+ */ +public class IfOsFunction implements ExpressionFunction { + + private final String name; + + private final Predicate condition; + + private IfOsFunction(String name, Predicate condition) { + + super(); + this.name = name; + this.condition = condition; + } + + @Override + public String getName() { + + return this.name; + } + + @Override + public int getMinArgs() { + + return 1; + } + + @Override + public int getMaxArgs() { + + return 1; + } + + @Override + public String apply(List args, ExpressionContext context) { + + if (this.condition.test(context.getIdeContext().getSystemInfo())) { + return args.get(0); + } + return ""; + } + + /** + * @return all instances of this {@link ExpressionFunction}. + */ + public static List all() { + + return List.of( // + new IfOsFunction("if-windows", SystemInfo::isWindows), // + new IfOsFunction("if-mac", SystemInfo::isMac), // + new IfOsFunction("if-linux", SystemInfo::isLinux), // + new IfOsFunction("if-unix", systemInfo -> !systemInfo.isWindows())); + } + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/function/PathFunction.java b/cli/src/main/java/com/devonfw/tools/ide/expression/function/PathFunction.java new file mode 100644 index 0000000000..476767d799 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/function/PathFunction.java @@ -0,0 +1,59 @@ +package com.devonfw.tools.ide.expression.function; + +import java.util.List; + +import com.devonfw.tools.ide.expression.ExpressionContext; +import com.devonfw.tools.ide.expression.ExpressionFunction; + +/** + * {@link ExpressionFunction} {@code @path} that normalises a path. + *
    + *
  1. the path to normalise. By default backslashes are replaced with slashes.
  2. + *
  3. optional: the literal value {@code unix} (default) or {@code native}.
  4. + *
+ * Example: {@code @path('$[IDE_HOME]/software/node/node.exe')} + */ +public class PathFunction implements ExpressionFunction { + + /** The literal value for the second argument to normalise to unix syntax (default). */ + public static final String MODE_UNIX = "unix"; + + /** The literal value for the second argument to normalise to the syntax native to the current operating system. */ + public static final String MODE_NATIVE = "native"; + + @Override + public String getName() { + + return "path"; + } + + @Override + public int getMinArgs() { + + return 1; + } + + @Override + public int getMaxArgs() { + + return 2; + } + + @Override + public String apply(List args, ExpressionContext context) { + + String path = args.get(0); + String mode = (args.size() > 1) ? args.get(1) : MODE_UNIX; + if (MODE_UNIX.equals(mode)) { + return path.replace('\\', '/'); + } else if (MODE_NATIVE.equals(mode)) { + if (context.getIdeContext().getSystemInfo().isWindows()) { + return path.replace('/', '\\'); + } + return path.replace('\\', '/'); + } + throw new IllegalArgumentException( + "Invalid mode '" + mode + "' for function @path - expected '" + MODE_UNIX + "' or '" + MODE_NATIVE + "'."); + } + +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java b/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java new file mode 100644 index 0000000000..d85ae99847 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java @@ -0,0 +1,339 @@ +package com.devonfw.tools.ide.expression; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.os.SystemInfoMock; + +/** + * Test of {@link ExpressionParser}. + */ +class ExpressionParserTest extends AbstractIdeContextTest { + + /** + * Test of {@code @path} with the default mode that replaces backslashes with slashes. + */ + @Test + void testPathUnixIsDefault() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + expressionContext.variables.put("IDE_HOME", "D:\\projects\\my-project"); + + // act + String result = expressionContext.resolve("@path('$[IDE_HOME]/software/mvn')"); + + // assert + assertThat(result).isEqualTo("D:/projects/my-project/software/mvn"); + } + + /** + * Test of {@code @path} with mode {@code native} on windows. + */ + @Test + void testPathNativeOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + TestExpressionContext expressionContext = new TestExpressionContext(context); + expressionContext.variables.put("IDE_HOME", "D:\\projects\\my-project"); + + // act + String result = expressionContext.resolve("@path('$[IDE_HOME]/software/node/node.exe', native)"); + + // assert + assertThat(result).isEqualTo("D:\\projects\\my-project\\software\\node\\node.exe"); + } + + /** + * Test that a backslash inside a quoted argument is never interpreted as an escape character, since arguments + * regularly contain native windows paths. + */ + @Test + void testBackslashIsNotAnEscapeCharacter() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@path('C:\\Users\\login\\next')"); + + // assert + assertThat(result).isEqualTo("C:/Users/login/next"); + } + + /** + * Test that a quoted argument may contain the argument separator and the closing parenthesis. This is the reason why + * the argument list cannot be parsed with a regular expression. + */ + @Test + void testQuotedArgumentMayContainCommaAndParenthesis() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("token-value"); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-secret('AI_API_KEY', 'Enter your key (from the portal), please:')"); + + // assert + assertThat(result).isEqualTo("token-value"); + assertThat(context).log() + .hasEntries(new IdeLogEntry(IdeLogLevel.INTERACTION, "Enter your key (from the portal), please:", true)); + } + + /** + * Test that a function may be nested inside the argument of another function. + */ + @Test + void testNestedFunction() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@if-windows('@path(C:/a/b, native)')"); + + // assert + assertThat(result).isEqualTo("C:\\a\\b"); + } + + /** + * Test that an expression of a foreign syntax is passed through entirely untouched. IDEasy must never try to resolve + * placeholders that belong to another tool. + * + * @param value the value that must not be modified. + */ + @ParameterizedTest + @ValueSource(strings = { // + "@media (max-width: 600px) { a: 1 }", // + "@media(max-width:600px){a:1}", // + "@include button-variant($primary);", // + "@Override @SuppressWarnings(\"unchecked\")", // + "@param foo the foo", // + "\"@angular/core\": \"^17.0.0\"", // + "contact: dev@example.com", // + "@path('unbalanced'" }) + void testForeignExpressionIsUntouched(String value) { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve(value); + + // assert + assertThat(result).isEqualTo(value); + } + + /** + * Test that an already defined variable is returned without asking the user. + */ + @Test + void testDefinedVariableIsNotAsked() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + expressionContext.variables.put("AI_BACKEND_URL", "http://llama.local"); + + // act + String result = expressionContext.resolve("@ask-variable('AI_BACKEND_URL')"); + + // assert + assertThat(result).isEqualTo("http://llama.local"); + assertThat(expressionContext.persisted).isEmpty(); + } + + /** + * Test that an undefined variable is asked with the default question and persisted for workspace templates. + */ + @Test + void testUndefinedVariableIsAskedAndPersisted() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("http://llama.local"); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-variable('AI_BACKEND_URL')"); + + // assert + assertThat(result).isEqualTo("http://llama.local"); + assertThat(context).log().hasEntries( + new IdeLogEntry(IdeLogLevel.INTERACTION, "Please enter the value for the variable AI_BACKEND_URL:", true)); + assertThat(expressionContext.persisted).containsExactly(Map.entry("AI_BACKEND_URL", "http://llama.local")); + } + + /** + * Test that a settings template does not persist the entered value since it is only instantiated once. + */ + @Test + void testSettingsTemplateDoesNotPersist() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("value"); + TestExpressionContext expressionContext = new TestExpressionContext(context); + expressionContext.persistent = false; + + // act + String result = expressionContext.resolve("@ask-variable('MY_VARIABLE')"); + + // assert + assertThat(result).isEqualTo("value"); + assertThat(expressionContext.persisted).isEmpty(); + } + + /** + * Test that an empty 1st argument always asks the user and never persists. + */ + @Test + void testEmptyVariableNameAlwaysAsks() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("first", "second"); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-variable('', 'Question A:')@ask-variable('', 'Question B:')"); + + // assert + assertThat(result).isEqualTo("firstsecond"); + assertThat(expressionContext.persisted).isEmpty(); + } + + /** + * Test that the 3rd argument allows an empty value to be entered. This is the intended way to permit an empty + * password in test or development scenarios. + */ + @Test + void testEmptyDefaultAllowsEmptyInput() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers(""); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-secret('OPTIONAL_PASSWORD', 'Password (may be empty):', '')"); + + // assert + assertThat(result).isEmpty(); + } + + /** + * Test that an empty 1st argument without an explicit question is rejected. + */ + @Test + void testEmptyVariableNameRequiresQuestion() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + assert + assertThatThrownBy(() -> expressionContext.resolve("@ask-variable('')")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires an explicit question"); + } + + /** + * Test that an invalid number of arguments is rejected. + */ + @Test + void testInvalidArgumentCount() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + assert + assertThatThrownBy(() -> expressionContext.resolve("@path(a, unix, extra)")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("requires 1 to 2 argument(s) but received 3"); + } + + /** + * Simple {@link ExpressionContext} for testing that also simulates the surrounding variable resolution of + * {@code AbstractEnvironmentVariables}. + */ + private static class TestExpressionContext implements ExpressionContext { + + private static final Pattern SQUARE = Pattern.compile("\\$\\[([a-zA-Z0-9_-]+)\\]"); + + private final ExpressionParser parser = new ExpressionParser(ExpressionFunctionManager.get()); + + private final Map variables = new HashMap<>(); + + private final Map persisted = new LinkedHashMap<>(); + + private final IdeContext ideContext; + + private boolean persistent = true; + + private TestExpressionContext(IdeContext ideContext) { + + super(); + this.ideContext = ideContext; + } + + @Override + public String resolve(String value) { + + String result = this.parser.resolve(value, this); + Matcher matcher = SQUARE.matcher(result); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String variableValue = this.variables.get(matcher.group(1)); + matcher.appendReplacement(sb, Matcher.quoteReplacement(variableValue == null ? matcher.group() : variableValue)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + @Override + public IdeContext getIdeContext() { + + return this.ideContext; + } + + @Override + public String getVariable(String name) { + + return this.variables.get(name); + } + + @Override + public void setVariable(String name, String value) { + + this.persisted.put(name, value); + this.variables.put(name, value); + } + + @Override + public boolean isPersistent() { + + return this.persistent; + } + } +} From a41eace5991443bd62a337dddac810db9ea10ebc Mon Sep 17 00:00:00 2001 From: Paras14 Date: Thu, 6 Aug 2026 15:21:05 +0200 Subject: [PATCH 2/5] #989: resolve expressions during variable resolution --- .../AbstractEnvironmentVariables.java | 70 +++++++++++++++- .../environment/EnvironmentVariablesTest.java | 83 ++++++++++++++++++- .../merge/DirectoryMergerExpressionTest.java | 57 +++++++++++++ .../update/config/ai.properties | 4 + 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java create mode 100644 cli/src/test/resources/templates-expression/update/config/ai.properties diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java b/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java index cc86408b7c..96b88d91e8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java @@ -12,6 +12,9 @@ import org.slf4j.event.Level; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.expression.ExpressionContext; +import com.devonfw.tools.ide.expression.ExpressionFunctionManager; +import com.devonfw.tools.ide.expression.ExpressionParser; import com.devonfw.tools.ide.variable.IdeVariables; import com.devonfw.tools.ide.variable.VariableDefinition; import com.devonfw.tools.ide.variable.VariableSyntax; @@ -34,6 +37,8 @@ public abstract class AbstractEnvironmentVariables implements EnvironmentVariabl private static final int MAX_RECURSION = 9; + private static final ExpressionParser EXPRESSION_PARSER = new ExpressionParser(ExpressionFunctionManager.get()); + /** * @see #getParent() */ @@ -206,14 +211,16 @@ private String resolveRecursive(String value, Object source, int recursion, Abst } recursion++; + String value2 = EXPRESSION_PARSER.resolve(value, new EnvironmentExpressionContext(source, recursion, resolvedVars, context)); + String resolved; if (context.syntax == null) { - resolved = resolveWithSyntax(value, source, recursion, resolvedVars, context, VariableSyntax.SQUARE); + resolved = resolveWithSyntax(value2, source, recursion, resolvedVars, context, VariableSyntax.SQUARE); if (context.legacySupport) { resolved = resolveWithSyntax(resolved, source, recursion, resolvedVars, context, VariableSyntax.CURLY); } } else { - resolved = resolveWithSyntax(value, source, recursion, resolvedVars, context, context.syntax); + resolved = resolveWithSyntax(value2, source, recursion, resolvedVars, context, context.syntax); } return resolved; } @@ -357,6 +364,65 @@ public String toString() { return getSource().toString(); } + /** + * Implementation of {@link ExpressionContext} that connects an {@link com.devonfw.tools.ide.expression.ExpressionFunction} with this + * {@link EnvironmentVariables} hierarchy. + */ + private final class EnvironmentExpressionContext implements ExpressionContext { + + private final Object src; + + private final int recursion; + + private final AbstractEnvironmentVariables resolvedVars; + + private final ResolveContext context; + + private EnvironmentExpressionContext(Object src, int recursion, AbstractEnvironmentVariables resolvedVars, ResolveContext context) { + + super(); + this.src = src; + this.recursion = recursion; + this.resolvedVars = resolvedVars; + this.context = context; + } + + @Override + public IdeContext getIdeContext() { + + return AbstractEnvironmentVariables.this.context; + } + + @Override + public String resolve(String value) { + + return this.resolvedVars.resolveRecursive(value, this.src, this.recursion, this.resolvedVars, this.context); + } + + @Override + public String getVariable(String name) { + + return this.resolvedVars.getValue(name, false); + } + + @Override + public void setVariable(String name, String value) { + + EnvironmentVariables conf = getByType(EnvironmentVariablesType.CONF); + if (conf instanceof EnvironmentVariablesPropertiesFile propertiesFile) { + propertiesFile.set(name, value); + propertiesFile.save(); + } else { + LOG.warn("Cannot persist variable {} since no configuration file is available.", name); + } + } + + @Override + public boolean isPersistent() { + return true; + } + } + /** * Simple record for the immutable arguments of recursive resolve methods. * diff --git a/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java b/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java index 98619cb78f..70d2f1b93b 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java @@ -131,8 +131,8 @@ void testUserDefinedMavenArgsIsMergedWithIdeasyDefaults() { } /** - * Test that IDEasy's {@code -s} and {@code -Dsettings.security=} arguments override any user-provided ones - * and that unrelated user arguments are correctly appended. + * Test that IDEasy's {@code -s} and {@code -Dsettings.security=} arguments override any user-provided ones and that unrelated user arguments are correctly + * appended. */ @Test void testMergeMavenArgsWithDefault() { @@ -154,4 +154,83 @@ void testMergeMavenArgsWithDefault() { assertThat(AbstractEnvironmentVariables.mergeWithDefault("-Xmx8000m -s invalid/settings.xml", null)) .isEqualTo("-Xmx8000m -s invalid/settings.xml"); } + + /** + * Test of {@link EnvironmentVariables#resolve(String, Object)} with an {@code @ask-variable} expression for an undefined variable. The user is asked and the + * entered value is persisted to {@code conf/ide.properties} so that the question is only asked once. + */ + @Test + void testResolveAskVariableExpressionPromptsAndPersists() { + + // arrange + String path = "project/workspaces/foo-test/my-git-repo"; + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, true); + context.setAnswers("http://llama.local"); + EnvironmentVariables variables = context.getVariables(); + + // act + String resolved = variables.resolve("url=@ask-variable('AI_BACKEND_URL')", "test", false); + + // assert + assertThat(resolved).isEqualTo("url=http://llama.local"); + assertThat(context.getVariables().get("AI_BACKEND_URL")).isEqualTo("http://llama.local"); + } + + /** + * Test that an {@code @ask-variable} expression for an already defined variable behaves exactly like a plain variable and does not interact with the user. + */ + @Test + void testResolveAskVariableExpressionUsesDefinedVariableWithoutInteraction() { + + // arrange + String path = "project/workspaces/foo-test/my-git-repo"; + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, false); + EnvironmentVariables variables = context.getVariables(); + + // act + String askExpression = variables.resolve("@ask-variable('TEST_ARGS4')", "test", false); + String plainVariable = variables.resolve("$[TEST_ARGS4]", "test", false); + + // assert + assertThat(askExpression).isEqualTo(plainVariable); + assertThat(askExpression).endsWith(" settings4"); + } + + /** + * Test of {@link EnvironmentVariables#resolve(String, Object)} with a {@code @path} expression whose argument contains a variable. + */ + @Test + void testResolvePathExpressionWithVariableArgument() { + + // arrange + String path = "project/workspaces/foo-test/my-git-repo"; + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, false); + EnvironmentVariables variables = context.getVariables(); + + // act + String resolved = variables.resolve("@path('$[IDE_HOME]/software/mvn')", "test", false); + + // assert + assertThat(resolved).doesNotContain("\\\\"); + assertThat(resolved).endsWith("/software/mvn"); + } + + /** + * Test that text which does not call a registered expression function is passed through untouched. + */ + @Test + void testResolveLeavesForeignExpressionUntouched() { + + // arrange + String path = "project/workspaces/foo-test/my-git-repo"; + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, false); + EnvironmentVariables variables = context.getVariables(); + + // act + String resolved = variables.resolve("@media(max-width:600px){a:1}", "test", false); + + // assert + assertThat(resolved).isEqualTo("@media(max-width:600px){a:1}"); + } + } diff --git a/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java new file mode 100644 index 0000000000..50cda7f9a3 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java @@ -0,0 +1,57 @@ +package com.devonfw.tools.ide.merge; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.context.IdeTestContext; + +/** + * Integration test of expressions (see {@link com.devonfw.tools.ide.expression.ExpressionParser}) applied to a workspace template by the + * {@link DirectoryMerger}. + */ +class DirectoryMergerExpressionTest extends AbstractIdeContextTest { + + /** + * Test that expressions in a workspace template are resolved, that the user is asked for undefined variables and that the entered values are persisted to + * {@code conf/ide.properties}. + * + * @param workspaceDir the temporary folder to use as workspace for this test. + * @throws Exception on error. + */ + @Test + void testExpressionsInWorkspaceTemplate(@TempDir Path workspaceDir) throws Exception { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + // NOTE: the answers are consumed in the order the questions are asked. PropertiesMerger iterates the Properties + // and therefore does not preserve the order of the lines in the template file. + context.setAnswers("sk-TOPSECRET", "http://llama.local"); + DirectoryMerger merger = context.getWorkspaceMerger(); + Path templates = Path.of("src/test/resources/templates-expression"); + + // act + merger.merge(templates.resolve(IdeContext.FOLDER_SETUP), templates.resolve(IdeContext.FOLDER_UPDATE), context.getVariables(), workspaceDir); + + // assert + Properties properties = context.getFileAccess().readProperties(workspaceDir.resolve("config/ai.properties")); + assertThat(properties.getProperty("api.key")).isEqualTo("sk-TOPSECRET"); + assertThat(properties.getProperty("backend.url")).isEqualTo("http://llama.local"); + // foreign syntax must never be resolved by IDEasy + assertThat(properties.getProperty("css.rule")).isEqualTo("@media(max-width:600px)"); + // @path normalises the backslashes of a windows IDE_HOME + assertThat(properties.getProperty("node.path")).endsWith("/software/node/node").doesNotContain("\\"); + + // the values are persisted so that the user is only asked once + Path confProperties = context.getIdeHome().resolve("conf").resolve("ide.properties"); + assertThat(confProperties).exists(); + String conf = Files.readString(confProperties); + assertThat(conf).contains("AI_API_KEY=sk-TOPSECRET"); + assertThat(conf).contains("AI_BACKEND_URL=http://llama.local"); + } +} diff --git a/cli/src/test/resources/templates-expression/update/config/ai.properties b/cli/src/test/resources/templates-expression/update/config/ai.properties new file mode 100644 index 0000000000..eeb98c1c1b --- /dev/null +++ b/cli/src/test/resources/templates-expression/update/config/ai.properties @@ -0,0 +1,4 @@ +backend.url=@ask-variable('AI_BACKEND_URL') +api.key=@ask-secret('AI_API_KEY') +node.path=@path('$[IDE_HOME]/software/node/node') +css.rule=@media(max-width:600px) From d1945db531e407ff418132948278b5ada363210f Mon Sep 17 00:00:00 2001 From: Paras14 Date: Thu, 6 Aug 2026 16:23:21 +0200 Subject: [PATCH 3/5] #989: do not persist template variables that could not be asked in batch mode --- .../ide/expression/function/AskFunction.java | 25 +++++++---- .../ide/expression/ExpressionParserTest.java | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java b/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java index f026be184d..8af571e057 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/function/AskFunction.java @@ -7,8 +7,8 @@ import com.devonfw.tools.ide.expression.ExpressionFunction; /** - * {@link ExpressionFunction} {@code @ask-variable} that asks for a variable in plain text and {@code @ask-secret} that - * asks for a secret variable with masked input. + * {@link ExpressionFunction} {@code @ask-variable} that asks for a variable in plain text and {@code @ask-secret} that asks for a secret variable with masked + * input. *
    *
  1. the name of the requested variable. If the variable is already defined it is returned without asking. If the * empty string is given, the user is always asked.
  2. @@ -66,7 +66,7 @@ public String apply(List args, ExpressionContext context) { throw new IllegalArgumentException( "Function @" + this.name + " requires an explicit question as 2nd argument if the variable name is empty."); } - return ask(question, defaultValue, context); + return toResult(ask(question, defaultValue, context)); } String value = context.getVariable(variableName); if (value != null) { @@ -76,22 +76,31 @@ public String apply(List args, ExpressionContext context) { question = "Please enter the value for the " + (this.secret ? "secret " : "") + "variable " + variableName + ":"; } value = ask(question, defaultValue, context); + if (value == null) { + return ""; + } if (context.isPersistent()) { context.setVariable(variableName, value); } return value; } + private static String toResult(String value) { + + return (value == null) ? "" : value; + } + + /** + * @return the value entered by the user, the default value, or {@code null} if the user could not be asked (batch mode with force) and no default value was + * given. + */ private String ask(String question, String defaultValue, ExpressionContext context) { IdeContext ideContext = context.getIdeContext(); - String value; if (this.secret) { - value = ideContext.askForSecret(question, defaultValue); - } else { - value = ideContext.askForInput(question, defaultValue); + return ideContext.askForSecret(question, defaultValue); } - return (value == null) ? "" : value; + return ideContext.askForInput(question, defaultValue); } /** diff --git a/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java b/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java index d85ae99847..7ddafac7eb 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/expression/ExpressionParserTest.java @@ -243,6 +243,47 @@ void testEmptyDefaultAllowsEmptyInput() { assertThat(result).isEmpty(); } + /** + * Test that in batch mode with force enabled a variable without a default value resolves to the empty string and is NOT persisted, so that the user is asked + * again on the next interactive run. + */ + @Test + void testBatchModeWithForceDoesNotPersistMissingValue() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.getStartContext().setBatchMode(true); + context.getStartContext().setForceMode(true); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-secret('MY_TOKEN')"); + + // assert + assertThat(result).isEmpty(); + assertThat(expressionContext.persisted).isEmpty(); + } + + /** + * Test that in batch mode with force enabled an explicitly given default value is used and persisted. + */ + @Test + void testBatchModeWithForceUsesAndPersistsDefaultValue() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.getStartContext().setBatchMode(true); + context.getStartContext().setForceMode(true); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Question:', 'the-default')"); + + // assert + assertThat(result).isEqualTo("the-default"); + assertThat(expressionContext.persisted).containsExactly(Map.entry("MY_VARIABLE", "the-default")); + } + /** * Test that an empty 1st argument without an explicit question is rejected. */ From c01ce8c986a1175c338a497482e7d41e6e99efe7 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Fri, 7 Aug 2026 10:52:26 +0200 Subject: [PATCH 4/5] #989: Fix formatting issues --- .../java/com/devonfw/tools/ide/context/AbstractIdeContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 39c0b76aae..76648ff4a0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -76,9 +76,9 @@ import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.tool.npm.NpmRepository; import com.devonfw.tools.ide.tool.pip.PipRepository; +import com.devonfw.tools.ide.tool.python.PythonRepository; import com.devonfw.tools.ide.tool.repository.DefaultToolRepository; import com.devonfw.tools.ide.tool.repository.ToolRepository; -import com.devonfw.tools.ide.tool.python.PythonRepository; import com.devonfw.tools.ide.tool.uv.UvRepository; import com.devonfw.tools.ide.url.model.UrlMetadata; import com.devonfw.tools.ide.util.DateTimeUtil; From 53b84eac4c7c886c2eeb5eaa7b2a282056816543 Mon Sep 17 00:00:00 2001 From: Paras14 <53565432+Paras14@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:58:24 +0200 Subject: [PATCH 5/5] Update CHANGELOG for version 2026.08.002 Updated changelog for version 2026.08.002, including new features and bugfixes. --- CHANGELOG.adoc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index a5aaa83a06..bfe56be00e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,11 +2,18 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDEasy]. -== 2026.08.001 +== 2026.08.002 Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions + +The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/49?closed=1[milestone 2026.08.002]. + +== 2026.08.001 + +Release with new features and bugfixes: + * https://github.com/devonfw/IDEasy/issues/2187[#2187]: Start SoapUI commandlet in background * https://github.com/devonfw/IDEasy/issues/2189[#2189]: Integrate Ruff * https://github.com/devonfw/IDEasy/issues/2126[#2126]: Fix language selection dropdown