From 876237605a777bb08b5ab1f5ed94a8e4e1cb5c44 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Thu, 6 Aug 2026 09:55:04 +0200 Subject: [PATCH 01/12] #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 02/12] #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 03/12] #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 04/12] #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 05/12] 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 From 3aa99ce300d24c048b2348d718dfc2cc11b24511 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Tue, 25 Aug 2026 00:58:38 +0200 Subject: [PATCH 06/12] #989: Add config location and default value arguments to @ask functions --- .../AbstractEnvironmentVariables.java | 13 +- .../ide/expression/ExpressionContext.java | 16 +-- .../ide/expression/function/AskFunction.java | 68 +++++++++-- .../ide/expression/ExpressionParserTest.java | 114 +++++++++++++----- 4 files changed, 151 insertions(+), 60 deletions(-) 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 96b88d91e8..9961acc253 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 @@ -406,21 +406,16 @@ public String getVariable(String name) { } @Override - public void setVariable(String name, String value) { + public void setVariable(String name, String value, EnvironmentVariablesType type) { - EnvironmentVariables conf = getByType(EnvironmentVariablesType.CONF); - if (conf instanceof EnvironmentVariablesPropertiesFile propertiesFile) { + EnvironmentVariables variables = getByType(type); + if (variables instanceof EnvironmentVariablesPropertiesFile propertiesFile) { propertiesFile.set(name, value); propertiesFile.save(); } else { - LOG.warn("Cannot persist variable {} since no configuration file is available.", name); + LOG.warn("Cannot persist variable {} since no configuration file is available for {}.", name, type); } } - - @Override - public boolean isPersistent() { - return true; - } } /** 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 index d0c3455cc0..261fc5b6d4 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionContext.java @@ -1,6 +1,7 @@ package com.devonfw.tools.ide.expression; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; /** * Interface for the context available to an {@link ExpressionFunction} while an expression is evaluated. @@ -28,20 +29,13 @@ public interface ExpressionContext { 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}. + * Persists the given variable to the {@code ide.properties} of the given {@link EnvironmentVariablesType configuration location} so the user is not asked + * again. * * @param name the name of the variable. * @param value the value to persist. + * @param type the {@link EnvironmentVariablesType} determining the {@code ide.properties} to write to. */ - 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(); + void setVariable(String name, String value, EnvironmentVariablesType type); } 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 8af571e057..63bbc509f0 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 @@ -1,23 +1,30 @@ package com.devonfw.tools.ide.expression.function; import java.util.List; +import java.util.Locale; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariablesFiles; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; 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. + * {@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. + *
    7. optional: the configuration location to persist the variable to, analogous to the {@code --cfg} option: + * {@code settings}, {@code workspace}, {@code conf} or {@code home} ({@code user}). Defaults to {@code conf}.
    8. + *
    9. optional: a default value. It is appended to the question in angled brackets so the user can just hit return. + * Provide the empty string ({@code ''}) to allow empty input. If omitted or given as {@code null}, empty input is not + * allowed and the user is asked again.
    10. *
    - * Example: {@code @ask-secret('AI_API_KEY', 'Please enter your API key for the AI backend:')} + * Example: {@code @ask-secret('AI_API_KEY', 'Please enter your API key:', conf)} */ public class AskFunction implements ExpressionFunction { @@ -25,6 +32,12 @@ public class AskFunction implements ExpressionFunction { private static final String NAME_SECRET = "ask-secret"; + /** Literal value of the 4th argument meaning that there is no default value. */ + private static final String NULL_VALUE = "null"; + + /** Alias for {@link EnvironmentVariablesFiles#USER} as the configuration location. */ + private static final String LOCATION_HOME = "HOME"; + private final String name; private final boolean secret; @@ -51,7 +64,7 @@ public int getMinArgs() { @Override public int getMaxArgs() { - return 3; + return 4; } @Override @@ -59,13 +72,19 @@ 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; + EnvironmentVariablesType location = toLocation((args.size() > 2) ? args.get(2) : null); + String defaultValue = (args.size() > 3) ? args.get(3) : null; + if (NULL_VALUE.equals(defaultValue)) { + // an explicit "null" means no default value, so empty input is not allowed + defaultValue = 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."); } + // the user is always asked and nothing is persisted since we have no name to persist under return toResult(ask(question, defaultValue, context)); } String value = context.getVariable(variableName); @@ -77,14 +96,36 @@ public String apply(List args, ExpressionContext context) { } value = ask(question, defaultValue, context); if (value == null) { + // In batch mode with force enabled the user cannot be asked and no default value was given. The expression + // resolves to the empty string but is NOT persisted: otherwise the variable would be defined as empty forever + // and the user would never be asked again on the next interactive run. return ""; } - if (context.isPersistent()) { - context.setVariable(variableName, value); - } + context.setVariable(variableName, value, location); return value; } + /** + * @param arg the 3rd argument or {@code null} if omitted. + * @return the {@link EnvironmentVariablesType} to persist to. + */ + private EnvironmentVariablesType toLocation(String arg) { + + if ((arg == null) || arg.isEmpty()) { + return EnvironmentVariablesType.CONF; + } + String location = arg.trim().toUpperCase(Locale.ROOT); + if (LOCATION_HOME.equals(location)) { + location = EnvironmentVariablesFiles.USER.name(); + } + try { + return EnvironmentVariablesFiles.valueOf(location).toType(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid configuration location '" + arg + "' for function @" + this.name + + " - expected one of settings, workspace, conf or home.", e); + } + } + private static String toResult(String value) { return (value == null) ? "" : value; @@ -96,11 +137,16 @@ private static String toResult(String value) { */ private String ask(String question, String defaultValue, ExpressionContext context) { + String prompt = question; + if (defaultValue != null) { + // show the default so the user can just hit return + prompt = question + " <" + defaultValue + ">"; + } IdeContext ideContext = context.getIdeContext(); if (this.secret) { - return ideContext.askForSecret(question, defaultValue); + return ideContext.askForSecret(prompt, defaultValue); } - return ideContext.askForInput(question, defaultValue); + return ideContext.askForInput(prompt, 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 7ddafac7eb..430a4de580 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 @@ -13,6 +13,7 @@ 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.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemInfoMock; @@ -185,26 +186,6 @@ void testUndefinedVariableIsAskedAndPersisted() { 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. */ @@ -237,7 +218,7 @@ void testEmptyDefaultAllowsEmptyInput() { TestExpressionContext expressionContext = new TestExpressionContext(context); // act - String result = expressionContext.resolve("@ask-secret('OPTIONAL_PASSWORD', 'Password (may be empty):', '')"); + String result = expressionContext.resolve("@ask-secret('OPTIONAL_PASSWORD', 'Password (may be empty):', conf, '')"); // assert assertThat(result).isEmpty(); @@ -277,13 +258,91 @@ void testBatchModeWithForceUsesAndPersistsDefaultValue() { TestExpressionContext expressionContext = new TestExpressionContext(context); // act - String result = expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Question:', 'the-default')"); + String result = expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Question:', conf, 'the-default')"); // assert assertThat(result).isEqualTo("the-default"); assertThat(expressionContext.persisted).containsExactly(Map.entry("MY_VARIABLE", "the-default")); } + /** + * Test that the 3rd argument selects the configuration location the variable is persisted to. + */ + @Test + void testConfigLocationArgument() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("value-a", "value-b", "value-c"); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + expressionContext.resolve("@ask-variable('VAR_SETTINGS', 'Q:', settings)"); + expressionContext.resolve("@ask-variable('VAR_HOME', 'Q:', home)"); + expressionContext.resolve("@ask-variable('VAR_DEFAULT', 'Q:')"); + + // assert + assertThat(expressionContext.locations).containsExactly( // + Map.entry("VAR_SETTINGS", EnvironmentVariablesType.SETTINGS), // + Map.entry("VAR_HOME", EnvironmentVariablesType.USER), // + Map.entry("VAR_DEFAULT", EnvironmentVariablesType.CONF)); + } + + /** + * Test that an invalid configuration location is rejected. + */ + @Test + void testInvalidConfigLocation() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + assert + assertThatThrownBy(() -> expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Q:', somewhere)")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid configuration location 'somewhere'"); + } + + /** + * Test that the default value is appended to the question in angled brackets. + */ + @Test + void testDefaultValueIsAppendedToQuestion() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers(""); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Please enter the value:', conf, 'the-default')"); + + // assert + assertThat(result).isEqualTo("the-default"); + assertThat(context).log() + .hasEntries(new IdeLogEntry(IdeLogLevel.INTERACTION, "Please enter the value: ", true)); + } + + /** + * Test that an explicit {@code null} as 4th argument means there is no default value and no suffix is appended. + */ + @Test + void testExplicitNullMeansNoDefaultValue() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("typed-value"); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Please enter the value:', conf, null)"); + + // assert + assertThat(result).isEqualTo("typed-value"); + assertThat(context).log() + .hasEntries(new IdeLogEntry(IdeLogLevel.INTERACTION, "Please enter the value:", true)); + } + /** * Test that an empty 1st argument without an explicit question is rejected. */ @@ -328,9 +387,10 @@ private static class TestExpressionContext implements ExpressionContext { private final Map persisted = new LinkedHashMap<>(); + private final Map locations = new LinkedHashMap<>(); + private final IdeContext ideContext; - private boolean persistent = true; private TestExpressionContext(IdeContext ideContext) { @@ -365,16 +425,12 @@ public String getVariable(String name) { } @Override - public void setVariable(String name, String value) { + public void setVariable(String name, String value, EnvironmentVariablesType type) { this.persisted.put(name, value); + this.locations.put(name, type); this.variables.put(name, value); } - @Override - public boolean isPersistent() { - - return this.persistent; - } } } From cb7b7cf3fb23ea5b3ea4386750a9d81d2e3395ee Mon Sep 17 00:00:00 2001 From: Paras14 Date: Tue, 25 Aug 2026 01:12:47 +0200 Subject: [PATCH 07/12] #989: Use WindowsPathSyntax for @path with native mode --- .../ide/expression/function/PathFunction.java | 3 +- .../ide/expression/ExpressionParserTest.java | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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 index 476767d799..908a554eb9 100644 --- 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 @@ -4,6 +4,7 @@ import com.devonfw.tools.ide.expression.ExpressionContext; import com.devonfw.tools.ide.expression.ExpressionFunction; +import com.devonfw.tools.ide.os.WindowsPathSyntax; /** * {@link ExpressionFunction} {@code @path} that normalises a path. @@ -48,7 +49,7 @@ public String apply(List args, ExpressionContext context) { return path.replace('\\', '/'); } else if (MODE_NATIVE.equals(mode)) { if (context.getIdeContext().getSystemInfo().isWindows()) { - return path.replace('/', '\\'); + return WindowsPathSyntax.WINDOWS.normalize(path).replace('/', '\\'); } return path.replace('\\', '/'); } 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 430a4de580..c4d6db05ff 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 @@ -60,6 +60,43 @@ void testPathNativeOnWindows() { assertThat(result).isEqualTo("D:\\projects\\my-project\\software\\node\\node.exe"); } + /** + * Test that {@code @path} with mode {@code native} converts an absolute MSYS path (git-bash) to the according windows drive instead of turning the drive + * letter into a folder. + */ + @Test + void testPathNativeConvertsMsysPathOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@path('/d/projects/my-project/software/mvn', native)"); + + // assert + assertThat(result).isEqualTo("D:\\projects\\my-project\\software\\mvn"); + } + + /** + * Test that {@code @path} with mode {@code native} still converts the separators of a relative path on windows. + */ + @Test + void testPathNativeConvertsRelativePathOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + String result = expressionContext.resolve("@path('software/node/node.exe', native)"); + + // assert + assertThat(result).isEqualTo("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. From 3e4dedc3b7846dc360c6034b2fb13b80f9d50b90 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Tue, 25 Aug 2026 01:51:24 +0200 Subject: [PATCH 08/12] #989: Report template errors as CliException instead of internal error --- .../ide/expression/ExpressionParser.java | 21 +++++++++-------- .../ide/expression/function/AskFunction.java | 9 ++++---- .../ide/expression/function/PathFunction.java | 5 ++-- .../ide/expression/ExpressionParserTest.java | 23 ++++++++++++++++--- 4 files changed, 39 insertions(+), 19 deletions(-) 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 index ab63bbef07..ec58c9a760 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java @@ -8,17 +8,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.tools.ide.cli.CliException; + /** * 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. + * 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. + * 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 { @@ -86,9 +86,10 @@ private String apply(ExpressionFunction function, List args, String valu 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 + "'."); + // NOTE: the value is deliberately not part of the message since it may already contain a resolved secret + throw new CliException( + "Invalid template expression: function @" + function.getName() + " requires " + min + + (max < 0 ? " or more" : " to " + max) + " argument(s) but received " + size + "."); } String result = function.apply(args, context); return (result == null) ? "" : 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 index 63bbc509f0..a3142a9ecc 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 @@ -3,6 +3,7 @@ import java.util.List; import java.util.Locale; +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.environment.EnvironmentVariablesFiles; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; @@ -81,8 +82,8 @@ public String apply(List args, ExpressionContext context) { 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."); + throw new CliException("Invalid template expression: function @" + this.name + + " requires an explicit question as 2nd argument if the variable name is empty."); } // the user is always asked and nothing is persisted since we have no name to persist under return toResult(ask(question, defaultValue, context)); @@ -121,8 +122,8 @@ private EnvironmentVariablesType toLocation(String arg) { try { return EnvironmentVariablesFiles.valueOf(location).toType(); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid configuration location '" + arg + "' for function @" + this.name - + " - expected one of settings, workspace, conf or home.", e); + throw new CliException("Invalid template expression: invalid configuration location '" + arg + "' for function @" + + this.name + " - expected one of settings, workspace, conf or home.", e); } } 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 index 908a554eb9..cb94740989 100644 --- 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 @@ -2,6 +2,7 @@ import java.util.List; +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.expression.ExpressionContext; import com.devonfw.tools.ide.expression.ExpressionFunction; import com.devonfw.tools.ide.os.WindowsPathSyntax; @@ -53,8 +54,8 @@ public String apply(List args, ExpressionContext context) { } return path.replace('\\', '/'); } - throw new IllegalArgumentException( - "Invalid mode '" + mode + "' for function @path - expected '" + MODE_UNIX + "' or '" + MODE_NATIVE + "'."); + throw new CliException( + "Invalid template expression: 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 index c4d6db05ff..7a8fea8fcf 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 @@ -10,6 +10,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; @@ -337,7 +338,7 @@ void testInvalidConfigLocation() { // act + assert assertThatThrownBy(() -> expressionContext.resolve("@ask-variable('MY_VARIABLE', 'Q:', somewhere)")) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid configuration location 'somewhere'"); + .isInstanceOf(CliException.class).hasMessageContaining("invalid configuration location 'somewhere'"); } /** @@ -391,7 +392,7 @@ void testEmptyVariableNameRequiresQuestion() { TestExpressionContext expressionContext = new TestExpressionContext(context); // act + assert - assertThatThrownBy(() -> expressionContext.resolve("@ask-variable('')")).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> expressionContext.resolve("@ask-variable('')")).isInstanceOf(CliException.class) .hasMessageContaining("requires an explicit question"); } @@ -407,7 +408,23 @@ void testInvalidArgumentCount() { // act + assert assertThatThrownBy(() -> expressionContext.resolve("@path(a, unix, extra)")) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("requires 1 to 2 argument(s) but received 3"); + .isInstanceOf(CliException.class).hasMessageContaining("requires 1 to 2 argument(s) but received 3"); + } + + /** + * Test that an invalid mode for {@code @path} is rejected with a {@link CliException} so that a template authoring error is not reported as an internal error + * of IDEasy. + */ + @Test + void testInvalidPathMode() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + TestExpressionContext expressionContext = new TestExpressionContext(context); + + // act + assert + assertThatThrownBy(() -> expressionContext.resolve("@path('x', dos)")).isInstanceOf(CliException.class) + .hasMessageContaining("invalid mode 'dos'"); } /** From 3b2dd8c80cc146b17941abad464302a5c9e504fa Mon Sep 17 00:00:00 2001 From: Paras14 Date: Tue, 25 Aug 2026 09:10:09 +0200 Subject: [PATCH 09/12] #989: Mask secret variable values in log output --- .../tools/ide/context/AbstractIdeContext.java | 48 ++++++++++++++ .../devonfw/tools/ide/context/IdeContext.java | 16 +++++ .../environment/EnvironmentVariablesMap.java | 3 + .../ide/expression/function/AskFunction.java | 8 +++ .../ide/context/AbstractIdeTestContext.java | 30 ++++++++- .../environment/EnvironmentVariablesTest.java | 66 +++++++++++++++++++ .../ide/expression/ExpressionParserTest.java | 22 +++++++ 7 files changed, 190 insertions(+), 3 deletions(-) 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 b024e7949f..ab6cf2eca3 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 @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; @@ -17,6 +18,7 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.Properties; +import java.util.Set; import java.util.function.Predicate; import java.util.logging.FileHandler; import java.util.logging.LogManager; @@ -178,8 +180,18 @@ public abstract class AbstractIdeContext implements IdeContext, IdeLogArgFormatt private WindowsHelper windowsHelper; + /** The replacement used to mask a secret in log output. */ + private static final String SECRET_MASK = "********"; + + /** Minimum length of a value to be masked as a secret in log output. Masking a very short value would corrupt unrelated log messages. */ + private static final int SECRET_MIN_LENGTH = 3; + private final Map privacyMap; + private final Set secrets; + + private final Set secretVariables; + private Path bash; private boolean julConfigured; @@ -200,6 +212,8 @@ public AbstractIdeContext(IdeStartContextImpl startContext, Path workingDirector this.startContext = startContext; this.startContext.setArgFormatter(this); this.privacyMap = new HashMap<>(); + this.secrets = new HashSet<>(); + this.secretVariables = new HashSet<>(); this.systemInfo = SystemInfoImpl.INSTANCE; if (isTest()) { configureJavaUtilLogging(null); @@ -1050,9 +1064,41 @@ public String formatArgument(Object argument) { } result = PrivacyUtil.removeSensitivePathInformation(result); } + // Secrets are masked independent of the privacy mode: a value the user entered as a secret or that belongs to a + // variable marked as secret must never appear in any log output. This is done here since formatArgument is the + // single place all log arguments pass through, so no individual log statement can be forgotten. + for (String secret : this.secrets) { + result = result.replace(secret, SECRET_MASK); + } return result; } + @Override + public void addSecretVariable(String name) { + + if ((name != null) && !name.isEmpty()) { + this.secretVariables.add(name); + } + } + + @Override + public void addSecretValue(String name, String value) { + + if (this.secretVariables.contains(name)) { + addSecret(value); + } + } + + /** + * @param secret the secret value to mask in all log output. Ignored if {@code null} or shorter than {@link #SECRET_MIN_LENGTH}. + */ + protected void addSecret(String secret) { + + if ((secret != null) && (secret.length() >= SECRET_MIN_LENGTH)) { + this.secrets.add(secret); + } + } + /** * @param path the sensitive {@link Path} to * @param replacement the replacement to mask the {@link Path} in log output. @@ -1120,9 +1166,11 @@ public String askForSecret(String message, String defaultValue) { } String input = readSecretLine().trim(); if (!input.isEmpty()) { + addSecret(input); return input; } else { if (defaultValue != null) { + addSecret(defaultValue); return defaultValue; } } 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 b7cad0fee7..013b28127f 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 @@ -297,6 +297,22 @@ default String askForSecret(String message) { return askForSecret(message, null); } + /** + * Marks the variable with the given name as secret so that its value is masked in all log output, even if the value is not entered by the user but read from + * an existing {@code ide.properties}. + * + * @param name the name of the variable (e.g. "MY_API_TOKEN"). + */ + void addSecretVariable(String name); + + /** + * Registers the value of a variable as secret if the variable was marked via {@link #addSecretVariable(String)}. Has to be called before the value is logged. + * + * @param name the name of the variable. + * @param value the value of the variable. + */ + void addSecretValue(String name, String value); + /** * @param question the question to ask. * @param args arguments for filling the templates diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesMap.java b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesMap.java index a823229bb1..95cac5d512 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesMap.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesMap.java @@ -38,6 +38,9 @@ public String getFlat(String name) { if (value == null) { LOG.trace("{}: Variable {} is undefined.", getSource(), name); } else { + // register the value before it is logged so that a secret variable read from an existing ide.properties is + // masked as well, not only a value that was just entered by the user + this.context.addSecretValue(name, value); LOG.trace("{}: Variable {}={}", getSource(), name, value); WindowsPathSyntax pathSyntax = this.context.getPathSyntax(); if (pathSyntax != null) { 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 a3142a9ecc..c8ca216b48 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 @@ -26,6 +26,10 @@ * allowed and the user is asked again. *

* Example: {@code @ask-secret('AI_API_KEY', 'Please enter your API key:', conf)} + *

+ * Note: a value entered for {@code @ask-secret} is masked while typing and masked in all log output, but it is + * stored unencrypted in the according {@code ide.properties}. That file is user local and not committed to + * git. Encryption is out of scope here and tracked separately for the maven {@code settings.xml} case. */ public class AskFunction implements ExpressionFunction { @@ -88,6 +92,10 @@ public String apply(List args, ExpressionContext context) { // the user is always asked and nothing is persisted since we have no name to persist under return toResult(ask(question, defaultValue, context)); } + if (this.secret) { + // mark before reading so that the value is already masked when the read itself is logged + context.getIdeContext().addSecretVariable(variableName); + } String value = context.getVariable(variableName); if (value != null) { return value; diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java b/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java index 5928b3b55c..b02d2edf2b 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java @@ -45,6 +45,8 @@ public class AbstractIdeTestContext extends AbstractIdeContext { private String[] answers; + private int secretLineCount; + private int answerIndex; private final Map progressBarMap; @@ -154,12 +156,34 @@ private void requireMutable() { @Override protected String readLine() { + String answer = nextAnswer(); + IdeLogLevel.INTERACTION.log(LOG, answer); + return answer; + } + + @Override + protected String readSecretLine() { + + this.secretLineCount++; + // unlike readLine() the answer is deliberately NOT logged, just like a real console does not echo a secret + return nextAnswer(); + } + + private String nextAnswer() { + if (this.answerIndex >= this.answers.length) { throw new IllegalStateException("End of answers reached!"); } - String answer = this.answers[this.answerIndex++]; - IdeLogLevel.INTERACTION.log(LOG, answer); - return answer; + return this.answers[this.answerIndex++]; + } + + /** + * @return the number of times {@link #readSecretLine()} was called, so tests can verify that masked input was actually used instead of plain + * {@link #readLine()}. + */ + public int getSecretLineCount() { + + return this.secretLineCount; } /** 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 70d2f1b93b..19a202e418 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 @@ -6,6 +6,7 @@ import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.variable.IdeVariables; @@ -233,4 +234,69 @@ void testResolveLeavesForeignExpressionUntouched() { assertThat(resolved).isEqualTo("@media(max-width:600px){a:1}"); } + + /** + * Test that a value entered for {@code @ask-secret} is masked in all log output, in particular in the debug log written when it is persisted. + */ + @Test + void testEnteredSecretIsMaskedInLogOutput() { + + // arrange + String path = "project/workspaces/foo-test/my-git-repo"; + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, true); + context.setAnswers("sk-SUPERSECRET-123"); + EnvironmentVariables variables = context.getVariables(); + + // act + String resolved = variables.resolve("token=@ask-secret('MY_TOKEN')", "test", false); + + // assert + assertThat(resolved).isEqualTo("token=sk-SUPERSECRET-123"); + assertThat(context).log().hasNoMessageContaining("sk-SUPERSECRET-123"); + } + + /** + * Test that an already defined secret variable is masked in log output as well, although the user is not asked for it. This is the case on every run after + * the value has been persisted once. + */ + @Test + void testAlreadyDefinedSecretIsMaskedInLogOutput() { + + // arrange + String path = "project/workspaces/foo-test/my-git-repo"; + // TRACE level so that the "Variable MY_TOKEN=..." log written while reading the variable is captured + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, true, null, IdeLogLevel.TRACE); + EnvironmentVariables variables = context.getVariables(); + variables.getByType(EnvironmentVariablesType.CONF).set("MY_TOKEN", "sk-ALREADY-STORED-456"); + context.getTestStartContext().getEntries().clear(); + + // act + String resolved = variables.resolve("token=@ask-secret('MY_TOKEN')", "test", false); + + // assert + assertThat(resolved).isEqualTo("token=sk-ALREADY-STORED-456"); + assertThat(context.getSecretLineCount()).isZero(); // the user was NOT asked + assertThat(context).log().hasNoMessageContaining("sk-ALREADY-STORED-456"); + } + + /** + * Test that a plain variable is still logged normally so that debugging is not impaired. + */ + @Test + void testPlainVariableIsNotMasked() { + + // 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('MY_URL')", "test", false); + + // assert + assertThat(resolved).isEqualTo("url=http://llama.local"); + assertThat(context.getSecretLineCount()).isZero(); + } + } 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 7a8fea8fcf..eccc60fb53 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 @@ -427,6 +427,28 @@ void testInvalidPathMode() { .hasMessageContaining("invalid mode 'dos'"); } + /** + * Test that {@code @ask-secret} uses the masked input path and {@code @ask-variable} does not. + */ + @Test + void testSecretUsesMaskedInputPath() { + + // arrange + IdeTestContext secretContext = newContext(PROJECT_BASIC); + secretContext.setAnswers("sk-SUPERSECRET-123"); + IdeTestContext plainContext = newContext(PROJECT_BASIC); + plainContext.setAnswers("http://llama.local"); + + // act + new TestExpressionContext(secretContext).resolve("@ask-secret('MY_TOKEN')"); + new TestExpressionContext(plainContext).resolve("@ask-variable('MY_URL')"); + + // assert + assertThat(secretContext.getSecretLineCount()).isEqualTo(1); + assertThat(plainContext.getSecretLineCount()).isZero(); + assertThat(secretContext).log().hasNoMessageContaining("sk-SUPERSECRET-123"); + } + /** * Simple {@link ExpressionContext} for testing that also simulates the surrounding variable resolution of * {@code AbstractEnvironmentVariables}. From ca3d70b9f6599fe90a2ca2d6bd90198499e0f2a6 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Tue, 25 Aug 2026 10:01:40 +0200 Subject: [PATCH 10/12] #989: Do not print a stacktrace for expected template errors --- .../devonfw/tools/ide/merge/FileMerger.java | 4 +++ .../merge/DirectoryMergerExpressionTest.java | 30 +++++++++++++++++++ .../update/broken.properties | 1 + 3 files changed, 35 insertions(+) create mode 100644 cli/src/test/resources/templates-expression-invalid/update/broken.properties diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/FileMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/FileMerger.java index 63767d319d..cd32a264ad 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/FileMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/FileMerger.java @@ -9,6 +9,7 @@ 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.variable.IdeVariables; @@ -53,6 +54,9 @@ protected void copy(Path sourceFile, Path targetFile) { public final int merge(Path setup, Path update, EnvironmentVariables variables, Path workspace) { try { doMerge(setup, update, variables, workspace); + } catch (CliException e) { + LOG.error("Failed to merge workspace file {} with update template {} and setup file {}!\n{}", workspace, update, setup, e.getMessage()); + return 1; } catch (Exception e) { LOG.error("Failed to merge workspace file {} with update template {} and setup file {}!", workspace, update, setup, e); return 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 index 50cda7f9a3..e43827c320 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java @@ -2,6 +2,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Properties; import org.junit.jupiter.api.Test; @@ -10,6 +11,8 @@ 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.IdeLogLevel; +import com.devonfw.tools.ide.log.IdeLogEntry; /** * Integration test of expressions (see {@link com.devonfw.tools.ide.expression.ExpressionParser}) applied to a workspace template by the @@ -54,4 +57,31 @@ void testExpressionsInWorkspaceTemplate(@TempDir Path workspaceDir) throws Excep assertThat(conf).contains("AI_API_KEY=sk-TOPSECRET"); assertThat(conf).contains("AI_BACKEND_URL=http://llama.local"); } + + /** + * Test that an invalid expression in a workspace template is reported with a readable message and without a stacktrace, since it is an authoring error in the + * settings and not a technical error of IDEasy. + * + * @param workspaceDir the temporary folder to use as workspace for this test. + */ + @Test + void testInvalidExpressionIsReportedWithoutStacktrace(@TempDir Path workspaceDir) { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + DirectoryMerger merger = context.getWorkspaceMerger(); + Path templates = TEST_RESOURCES.resolve("templates-expression-invalid"); + + // act + merger.merge(templates.resolve(IdeContext.FOLDER_SETUP), templates.resolve(IdeContext.FOLDER_UPDATE), context.getVariables(), workspaceDir); + + // assert + List errors = context.getTestStartContext().getEntries().stream().filter(e -> e.level() == IdeLogLevel.ERROR).toList(); + assertThat(errors).isNotEmpty(); + IdeLogEntry error = errors.get(0); + assertThat(error.message()).contains("invalid mode 'dos'"); + // no exception is attached to the log entry, so no stacktrace is printed for the end-user + assertThat(error.error()).isNull(); + } + } diff --git a/cli/src/test/resources/templates-expression-invalid/update/broken.properties b/cli/src/test/resources/templates-expression-invalid/update/broken.properties new file mode 100644 index 0000000000..ae7240e1b2 --- /dev/null +++ b/cli/src/test/resources/templates-expression-invalid/update/broken.properties @@ -0,0 +1 @@ +broken=@path('x', dos) From 07af2e221bedfca352e4bda801c457f19b60c0cc Mon Sep 17 00:00:00 2001 From: Paras14 Date: Wed, 26 Aug 2026 09:18:52 +0200 Subject: [PATCH 11/12] #989: address review findings on documentation, duplication and ordering --- .../tools/ide/context/AbstractIdeContext.java | 47 ++++++------ .../AbstractEnvironmentVariables.java | 9 ++- .../ide/expression/ExpressionParser.java | 4 +- .../tools/ide/merge/PropertiesMerger.java | 10 +-- .../merge/DirectoryMergerExpressionTest.java | 7 +- documentation/configurator.adoc | 71 +++++++++++++++++++ 6 files changed, 111 insertions(+), 37 deletions(-) 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 c0616f32f1..7e3632cbe5 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 @@ -1128,31 +1128,27 @@ private void resetPrivacyMap() { @Override public String askForInput(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 = readLine().trim(); - if (!input.isEmpty()) { - return input; - } else { - if (defaultValue != null) { - return defaultValue; - } - } - } + return ask(message, defaultValue, false); } @Override public String askForSecret(String message, String defaultValue) { + return ask(message, defaultValue, true); + } + + /** + * Asks the user for a value, re-asking while the input is empty and a default value is given. + * + * @param message the question to ask. + * @param defaultValue the value to return if the user accepts the default (by entering an empty value) or {@code null} to re-ask until a value is + * entered. + * @param secret - {@code true} to read the input in a masked way (see {@link #readSecretLine()}) and to mask it in the log output, {@code false} to + * read it as plain text. + * @return the entered value or the default value. + */ + private String ask(String message, String defaultValue, boolean secret) { + while (true) { if (!message.isBlank()) { IdeLogLevel.INTERACTION.log(LOG, message); @@ -1164,13 +1160,18 @@ public String askForSecret(String message, String defaultValue) { throw new CliAbortException(); } } - String input = readSecretLine().trim(); + // for a secret the input is not trimmed so that a leading or trailing whitespace that is part of the password or a pasted token is preserved + String input = secret ? readSecretLine() : readLine().trim(); if (!input.isEmpty()) { - addSecret(input); + if (secret) { + addSecret(input); + } return input; } else { if (defaultValue != null) { - addSecret(defaultValue); + if (secret) { + addSecret(defaultValue); + } return defaultValue; } } 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 9961acc253..d591f0d180 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 @@ -211,16 +211,19 @@ private String resolveRecursive(String value, Object source, int recursion, Abst } recursion++; - String value2 = EXPRESSION_PARSER.resolve(value, new EnvironmentExpressionContext(source, recursion, resolvedVars, context)); + // Expressions are evaluated before plain variables so that a function argument may contain variables and so that a variable value can never change + // the structure of an enclosing expression (e.g. by containing a quote or a parenthesis). Note that the variable pass below still scans the result + // of a function, exactly like it scans the value of a plain variable, so a resolved value containing "$[" is resolved further. + String withExpressions = EXPRESSION_PARSER.resolve(value, new EnvironmentExpressionContext(source, recursion, resolvedVars, context)); String resolved; if (context.syntax == null) { - resolved = resolveWithSyntax(value2, source, recursion, resolvedVars, context, VariableSyntax.SQUARE); + resolved = resolveWithSyntax(withExpressions, source, recursion, resolvedVars, context, VariableSyntax.SQUARE); if (context.legacySupport) { resolved = resolveWithSyntax(resolved, source, recursion, resolvedVars, context, VariableSyntax.CURLY); } } else { - resolved = resolveWithSyntax(value2, source, recursion, resolvedVars, context, context.syntax); + resolved = resolveWithSyntax(withExpressions, source, recursion, resolvedVars, context, context.syntax); } return resolved; } 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 index ec58c9a760..d2848fc898 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java +++ b/cli/src/main/java/com/devonfw/tools/ide/expression/ExpressionParser.java @@ -59,7 +59,7 @@ public String resolve(String value, ExpressionContext context) { } StringBuilder sb = new StringBuilder(value.length() + EXTRA_CAPACITY); int pos = 0; - while (matcher.find(pos)) { + do { int start = matcher.start(); int open = matcher.end() - 1; String functionName = matcher.group(1); @@ -75,7 +75,7 @@ public String resolve(String value, ExpressionContext context) { List args = parseArguments(value, open + 1, close, context); sb.append(apply(function, args, value, context)); pos = close + 1; - } + } while (matcher.find(pos)); sb.append(value, pos, value.length()); return sb.toString(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/PropertiesMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/PropertiesMerger.java index 1ed2d25686..2c018c6e7f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/PropertiesMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/PropertiesMerger.java @@ -3,7 +3,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; -import java.util.Set; +import java.util.TreeSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,10 +57,10 @@ protected void doMerge(Path setup, Path update, EnvironmentVariables resolver, P private void resolve(Properties properties, EnvironmentVariables variables, Object src) { - Set keys = properties.keySet(); - for (Object key : keys) { - String value = properties.getProperty(key.toString()); - properties.setProperty(key.toString(), variables.resolve(value, src, this.legacySupport)); + // resolve the keys in a deterministic order (rather than the unspecified Properties/Hashtable order) so that + // interactive questions are asked in a stable order and the merged file comes out consistently + for (String key : new TreeSet<>(properties.stringPropertyNames())) { + properties.setProperty(key, variables.resolve(properties.getProperty(key), src, this.legacySupport)); } } 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 index e43827c320..25e00655f3 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java @@ -11,8 +11,8 @@ 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.IdeLogLevel; import com.devonfw.tools.ide.log.IdeLogEntry; +import com.devonfw.tools.ide.log.IdeLogLevel; /** * Integration test of expressions (see {@link com.devonfw.tools.ide.expression.ExpressionParser}) applied to a workspace template by the @@ -32,11 +32,10 @@ void testExpressionsInWorkspaceTemplate(@TempDir Path workspaceDir) throws Excep // 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. + // the answers are consumed in the order the questions are asked, i.e. the order the keys are resolved in the template context.setAnswers("sk-TOPSECRET", "http://llama.local"); DirectoryMerger merger = context.getWorkspaceMerger(); - Path templates = Path.of("src/test/resources/templates-expression"); + Path templates = TEST_RESOURCES.resolve("templates-expression"); // act merger.merge(templates.resolve(IdeContext.FOLDER_SETUP), templates.resolve(IdeContext.FOLDER_UPDATE), context.getVariables(), workspaceDir); diff --git a/documentation/configurator.adoc b/documentation/configurator.adoc index 6f9806a224..a294fb605d 100644 --- a/documentation/configurator.adoc +++ b/documentation/configurator.adoc @@ -54,6 +54,77 @@ If the user modifies such settings and reopens his IDE his changes are reverted. These such settings are managed and enforced for the project. Hence, use `update` for things such as code-formatters, compiler options, paths to tools shipped with `IDEasy`, etc. that should be consistent and homogeneous for every team-member. +== Template expressions + +Besides plain variables in the form `$[«variable-name»]`, a template value may contain an expression of the syntax `@«function-name»([«arg»[,«arg»]*])`. +This allows to apply a little transformation to a value or to ask the user for a value while the workspace is being created. + +=== Syntax + +* The function name consists of lowercase letters, digits and dashes and is written after a single `@` followed by a pair of parentheses, e.g. `@path(...)`. +* Arguments are always strings and are separated by a comma. +Spaces around an argument are ignored/trimmed. +* To pass a literal value that may contain the argument separator (a comma) or a parenthesis, quote it with single or double quotes, e.g. `@ask-secret('AI_API_KEY', 'Enter your key (from the portal):')`. +A backslash inside a quoted argument is a literal backslash and is not an escape character, since arguments regularly contain native Windows paths. +* An argument itself may contain plain variables, so `@path('$[IDE_HOME]/software/node/node')` first resolves `$[IDE_HOME]` and then normalizes the path. +Functions may also be nested, so an argument can itself contain a call of another function. + +=== Functions + +* `@path` normalizes a path. +The first argument is the path. +The second argument is optional and is the literal value `unix` (the default, which replaces backslashes with slashes) or `native` (which on Windows uses `WindowsPathSyntax.WINDOWS` and therefore backslashes). +This is the remedy for values that come out with a mixed up path syntax on Windows, e.g. +`$[IDE_HOME]/software/node/node.exe` resolves to `D:\projects\my-project/software/node/node.exe` where the backslashes of the drive letter and the slashes of the rest clash. ++ +[source] +---- +@path('$[IDE_HOME]/software/node/node', unix) +@path('$[IDE_HOME]/software/node/node.exe', native) +---- +* `@ask-variable` asks the user for the value of a variable in plain text, and `@ask-secret` does the same but reads the input with masked input. +The first argument is 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 and nothing is persisted. +The second argument is an optional explicit question used as the prompt. +If omitted, it defaults to `Please enter the value for the (secret) variable «NAME»:` and is required if the first argument is empty. +The third argument is an optional configuration location to persist the entered value to, analogous to the `--cfg` option: `settings`, `workspace`, `conf` or `home` (default: `conf`). +The fourth argument is an optional default value that is appended to the question in angled brackets so the user can just press return. +Provide the empty string (`''`) to allow an empty value. +If omitted or given as `null`, an empty value is not allowed and the user is asked again. ++ +[source] +---- +@ask-variable('AI_BACKEND_URL') +@ask-variable('AI_BACKEND_URL', 'Please enter the LLM backend URL:', conf, 'http://localhost:1234') +@ask-secret('AI_API_KEY', 'Please enter your API key:', conf) +---- +* `@if-windows`, `@if-mac`, `@if-linux` and `@if-unix` insert their single argument only if the current operating system matches and otherwise resolve to the empty string. +This allows to select an OS specific value inline. ++ +[source] +---- +$[IDE_HOME]/software/mvn/bin/mvn@if-windows('.bat')@if-unix('.sh') +---- + +A function that is not registered by `IDEasy` is passed through entirely untouched. +This is essential since foreign configuration formats may use an `@` for their own purposes (e.g. a CSS rule `@media(max-width:600px)` or an Eclipse `@param`) and `IDEasy` must never try to resolve placeholders that are not its own. + +=== Where the values of `@ask-*` are stored and how to use the functions + +* The value entered for an `@ask-*` call is persisted to the `ide.properties` of the chosen configuration location so that the user is asked only once. +In particular, the value entered for `@ask-secret` is _not_ encrypted; it is stored as plain text in `conf/ide.properties`. +That file is user-local and is not committed to git, and the value is masked in all log output, but the file itself is readable on the machine. +Encryption is a separate follow-up story (it also covers the case of the Maven `settings.xml`). +* Expressions belong in the workspace templates under `setup` and `update`. +Do not put an `@ask-*` call into a value of `ide.properties` itself: a value that contains an expression is resolved on _every_ variable resolution, so a `@ask-*` in `ide.properties` would prompt on every `ide` invocation (including `ide env`). +Expressions are typically used in the `update` templates to build up OS-specific or user-specific settings that would otherwise be hard to express with a plain variable, as in the following example that only resolves to a working value on the matching operating system: ++ +[source] +---- + +---- + == How to customize Unless you are already an expert and know where to tweak what, we recommend the following workflow to customize and tailor the IDE configuration to your needs: From 87828e441a4b122f7c56e6e980dc4d6ebec87b67 Mon Sep 17 00:00:00 2001 From: Paras14 Date: Wed, 26 Aug 2026 09:35:56 +0200 Subject: [PATCH 12/12] #989: Rename test values that look like real API keys --- .../environment/EnvironmentVariablesTest.java | 12 +++++------ .../ide/expression/ExpressionParserTest.java | 21 ++++++++----------- .../merge/DirectoryMergerExpressionTest.java | 6 +++--- 3 files changed, 18 insertions(+), 21 deletions(-) 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 f3bf43ddd3..f7c9f6e0f6 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 @@ -246,15 +246,15 @@ void testEnteredSecretIsMaskedInLogOutput() { // arrange String path = "project/workspaces/foo-test/my-git-repo"; IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, true); - context.setAnswers("sk-SUPERSECRET-123"); + context.setAnswers("dummy-secret-value"); EnvironmentVariables variables = context.getVariables(); // act String resolved = variables.resolve("token=@ask-secret('MY_TOKEN')", "test", false); // assert - assertThat(resolved).isEqualTo("token=sk-SUPERSECRET-123"); - assertThat(context).log().hasNoMessageContaining("sk-SUPERSECRET-123"); + assertThat(resolved).isEqualTo("token=dummy-secret-value"); + assertThat(context).log().hasNoMessageContaining("dummy-secret-value"); } /** @@ -269,16 +269,16 @@ void testAlreadyDefinedSecretIsMaskedInLogOutput() { // TRACE level so that the "Variable MY_TOKEN=..." log written while reading the variable is captured IdeTestContext context = newContext(ENVIRONMENT_PROJECT, path, true, null, IdeLogLevel.TRACE); EnvironmentVariables variables = context.getVariables(); - variables.getByType(EnvironmentVariablesType.CONF).set("MY_TOKEN", "sk-ALREADY-STORED-456"); + variables.getByType(EnvironmentVariablesType.CONF).set("MY_TOKEN", "dummy-stored-value"); context.getTestStartContext().getEntries().clear(); // act String resolved = variables.resolve("token=@ask-secret('MY_TOKEN')", "test", false); // assert - assertThat(resolved).isEqualTo("token=sk-ALREADY-STORED-456"); + assertThat(resolved).isEqualTo("token=dummy-stored-value"); assertThat(context.getSecretLineCount()).isZero(); // the user was NOT asked - assertThat(context).log().hasNoMessageContaining("sk-ALREADY-STORED-456"); + assertThat(context).log().hasNoMessageContaining("dummy-stored-value"); } /** 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 eccc60fb53..a60fb27a47 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 @@ -99,8 +99,7 @@ void testPathNativeConvertsRelativePathOnWindows() { } /** - * Test that a backslash inside a quoted argument is never interpreted as an escape character, since arguments - * regularly contain native windows paths. + * 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() { @@ -117,8 +116,8 @@ void testBackslashIsNotAnEscapeCharacter() { } /** - * 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 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() { @@ -156,8 +155,8 @@ void testNestedFunction() { } /** - * 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. + * 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. */ @@ -244,8 +243,7 @@ void testEmptyVariableNameAlwaysAsks() { } /** - * 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 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() { @@ -435,7 +433,7 @@ void testSecretUsesMaskedInputPath() { // arrange IdeTestContext secretContext = newContext(PROJECT_BASIC); - secretContext.setAnswers("sk-SUPERSECRET-123"); + secretContext.setAnswers("dummy-secret-value"); IdeTestContext plainContext = newContext(PROJECT_BASIC); plainContext.setAnswers("http://llama.local"); @@ -446,12 +444,11 @@ void testSecretUsesMaskedInputPath() { // assert assertThat(secretContext.getSecretLineCount()).isEqualTo(1); assertThat(plainContext.getSecretLineCount()).isZero(); - assertThat(secretContext).log().hasNoMessageContaining("sk-SUPERSECRET-123"); + assertThat(secretContext).log().hasNoMessageContaining("dummy-secret-value"); } /** - * Simple {@link ExpressionContext} for testing that also simulates the surrounding variable resolution of - * {@code AbstractEnvironmentVariables}. + * Simple {@link ExpressionContext} for testing that also simulates the surrounding variable resolution of {@code AbstractEnvironmentVariables}. */ private static class TestExpressionContext implements ExpressionContext { 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 index 25e00655f3..d22e1b9c96 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerExpressionTest.java @@ -33,7 +33,7 @@ void testExpressionsInWorkspaceTemplate(@TempDir Path workspaceDir) throws Excep // arrange IdeTestContext context = newContext(PROJECT_BASIC, null, true); // the answers are consumed in the order the questions are asked, i.e. the order the keys are resolved in the template - context.setAnswers("sk-TOPSECRET", "http://llama.local"); + context.setAnswers("dummy-secret-value", "http://llama.local"); DirectoryMerger merger = context.getWorkspaceMerger(); Path templates = TEST_RESOURCES.resolve("templates-expression"); @@ -42,7 +42,7 @@ void testExpressionsInWorkspaceTemplate(@TempDir Path workspaceDir) throws Excep // assert Properties properties = context.getFileAccess().readProperties(workspaceDir.resolve("config/ai.properties")); - assertThat(properties.getProperty("api.key")).isEqualTo("sk-TOPSECRET"); + assertThat(properties.getProperty("api.key")).isEqualTo("dummy-secret-value"); 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)"); @@ -53,7 +53,7 @@ void testExpressionsInWorkspaceTemplate(@TempDir Path workspaceDir) throws Excep 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_API_KEY=dummy-secret-value"); assertThat(conf).contains("AI_BACKEND_URL=http://llama.local"); }