This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new d27c674ff297 CAMEL-24703: camel-core-languages - Simple parser errors
say what to write (#26367)
d27c674ff297 is described below
commit d27c674ff2975435c56c4fad06152cc21fd28397
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Sep 13 16:45:36 2026 +0200
CAMEL-24703: camel-core-languages - Simple parser errors say what to write
(#26367)
CAMEL-24703: camel-core-languages - Simple parser errors say what to write
(functions, operators, OGNL, arithmetic, language-as-function)
Squash of the 10 commits on fix/CAMEL-24703 (the run-by-run history is on
bench/after-run).
camel-core-languages - simple parser messages say what to write
camel-bean - OGNL errors name the message body and suggest the closest
method
camel-api, camel-bean - a missing language names its dependency, an OGNL
method without parentheses says so
camel-core-languages - the file: functions say they describe the consumed
file and do not read one
restore generated files rewritten by a partial catalog build in the worktree
camel-bean - ${body.key} on a Map body says a key is read with ${body[key]}
camel-bean - 'no matching method could be found' names the bean's methods
and how to choose one
camel-core-languages - ${exchangeCounter % 3} is an unknown function that
says simple has no arithmetic
camel-bean - a method with several parameters invoked with only the body
says to pass the arguments in the method name
camel-core-languages - a language name used as a simple function
(${simple}, ${groovy(...)}) says what to write
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
.../org/apache/camel/catalog/CamelCatalogTest.java | 20 +-
.../component/bean/AbstractBeanProcessor.java | 28 +-
.../org/apache/camel/component/bean/BeanInfo.java | 44 +++
.../apache/camel/component/bean/MethodInfo.java | 32 ++-
.../apache/camel/language/bean/BeanExpression.java | 63 ++++-
.../org/apache/camel/NoSuchLanguageException.java | 4 +-
.../camel/language/simple/BaseSimpleParser.java | 14 +-
.../language/simple/SimpleExpressionParser.java | 6 +-
.../language/simple/SimplePredicateParser.java | 18 +-
.../camel/language/simple/SimpleSyntaxHints.java | 301 +++++++++++++++++++++
.../simple/ast/SimpleFunctionExpression.java | 20 +-
.../simple/functions/BodyFunctionFactory.java | 7 +-
.../simple/functions/ExchangeFunctionFactory.java | 3 +-
.../bean/BeanNoMatchingMethodHintTest.java | 77 ++++++
.../bean/BeanParameterInvalidValueTest.java | 2 +-
.../apache/camel/language/FileLanguageTest.java | 9 +-
.../apache/camel/language/NoSuchLanguageTest.java | 3 +-
.../language/simple/SimpleSyntaxHintsTest.java | 171 ++++++++++++
.../apache/camel/language/simple/SimpleTest.java | 4 +-
.../camel/management/BacklogDebuggerTest.java | 2 +-
20 files changed, 790 insertions(+), 38 deletions(-)
diff --git
a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java
b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java
index 06f7114c03f4..6dc1999f96f8 100644
---
a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java
+++
b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java
@@ -1206,15 +1206,16 @@ public class CamelCatalogTest {
assertFalse(result.isSuccess());
assertEquals("${body", result.getText());
LOG.info(result.getError());
- assertTrue(result.getError().startsWith("expected symbol functionEnd
but was eol at location 5"));
- assertEquals("expected symbol functionEnd but was eol",
result.getShortError());
+ assertTrue(result.getError().startsWith("expected symbol functionEnd
but was eol"));
+ assertTrue(result.getError().contains("missing } to close the function
at location 5"));
+ assertTrue(result.getShortError().startsWith("expected symbol
functionEnd but was eol"));
assertEquals(5, result.getIndex());
result = catalog.validateLanguageExpression(null, "simple",
"${bodyxxx}");
assertFalse(result.isSuccess());
assertEquals("${bodyxxx}", result.getText());
LOG.info(result.getError());
- assertEquals("Valid syntax: ${body.OGNL} was: bodyxxx",
result.getShortError());
+ assertEquals("Unknown function: bodyxxx (did you mean ${body}?)",
result.getShortError());
assertEquals(0, result.getIndex());
}
@@ -1228,8 +1229,9 @@ public class CamelCatalogTest {
assertFalse(result.isSuccess());
assertEquals("${body} > ${header.size", result.getText());
LOG.info(result.getError());
- assertTrue(result.getError().startsWith("expected symbol functionEnd
but was eol at location 22"));
- assertEquals("expected symbol functionEnd but was eol",
result.getShortError());
+ assertTrue(result.getError().startsWith("expected symbol functionEnd
but was eol"));
+ assertTrue(result.getError().contains("missing } to close the function
at location 22"));
+ assertTrue(result.getShortError().startsWith("expected symbol
functionEnd but was eol"));
assertEquals(22, result.getIndex());
}
@@ -1243,9 +1245,9 @@ public class CamelCatalogTest {
assertFalse(result.isSuccess());
assertEquals("${bdy} contains '{{danger}}'", result.getText());
LOG.info(result.getError());
- assertTrue(result.getError().startsWith("Unknown function: bdy at
location 0"));
+ assertTrue(result.getError().startsWith("Unknown function: bdy (did
you mean ${body}?) at location 0"));
assertTrue(result.getError().contains("'{{danger}}'"));
- assertEquals("Unknown function: bdy", result.getShortError());
+ assertEquals("Unknown function: bdy (did you mean ${body}?)",
result.getShortError());
assertEquals(0, result.getIndex());
}
@@ -1331,7 +1333,7 @@ public class CamelCatalogTest {
result = catalog.validateLanguagePredicate(null, "simple",
"${body.length} =!= 12");
assertFalse(result.isSuccess());
- assertEquals("Unexpected token =", result.getShortError());
+ assertTrue(result.getShortError().startsWith("Unknown operator =!=:
did you mean !=?"), result.getShortError());
result = catalog.validateLanguageExpression(null, "simple",
"${int:body}");
assertTrue(result.isSuccess());
@@ -1340,7 +1342,7 @@ public class CamelCatalogTest {
result = catalog.validateLanguageExpression(null, "simple",
"${unknown:body}");
assertFalse(result.isSuccess());
assertEquals("${unknown:body}", result.getText());
- assertEquals("Unknown function: unknown:body", result.getShortError());
+ assertTrue(result.getShortError().startsWith("Unknown function:
unknown:body"), result.getShortError());
}
@Test
diff --git
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
index 92412328f379..5b909e7a495f 100644
---
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
+++
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/AbstractBeanProcessor.java
@@ -111,7 +111,8 @@ public abstract class AbstractBeanProcessor extends
AsyncProcessorSupport {
if (invocation == null) {
exchange.setException(new IllegalStateException(
- "No method invocation could be created, no matching method
could be found on: " + beanInstance));
+ "No method invocation could be created, no matching method
could be found on: " + beanInstance
+ +
methodsHint(beanInfo, explicitMethodName)));
callback.done(true);
return true;
}
@@ -284,4 +285,29 @@ public abstract class AbstractBeanProcessor extends
AsyncProcessorSupport {
// fallback and allow using the processor
return true;
}
+
+ /**
+ * The public methods of the bean and how to choose one: a bean with
several methods and no method name cannot be
+ * called, and the message alone did not say what to write.
+ */
+ private static String methodsHint(BeanInfo beanInfo, String
explicitMethodName) {
+ try {
+ java.util.List<String> names = beanInfo.getMethods().stream()
+ .map(m -> m.getMethod().getName())
+ .distinct()
+ .sorted()
+ .toList();
+ if (names.isEmpty()) {
+ return " (the bean has no public method to call)";
+ }
+ if (explicitMethodName != null) {
+ return " (method " + explicitMethodName + " was asked for; the
bean has: " + String.join(", ", names)
+ + ")";
+ }
+ return " (the bean has the methods " + String.join(", ", names)
+ + ": name the one to call with method: <name> on the bean
step, or annotate it with @Handler)";
+ } catch (Exception e) {
+ return "";
+ }
+ }
}
diff --git
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/BeanInfo.java
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/BeanInfo.java
index 9619d58b9ba1..ee174f94493d 100644
---
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/BeanInfo.java
+++
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/BeanInfo.java
@@ -220,6 +220,10 @@ public class BeanInfo {
methodInfo = evalMethods(pojo, exchange, methodName,
emptyParameters, name, methods);
} else {
// a specific method was given to invoke but not found
+ String closest = closestMethodName(name);
+ if (closest != null) {
+ throw new MethodNotFoundException(exchange, pojo,
methodName, "(did you mean " + closest + "()?)");
+ }
throw new MethodNotFoundException(exchange, pojo,
methodName);
}
}
@@ -264,6 +268,46 @@ public class BeanInfo {
return methodInfo;
}
+ /**
+ * The public method name of the bean closest to the given one (a typo
such as lenght for length), or null.
+ */
+ private String closestMethodName(String name) {
+ if (name == null || name.length() < 3) {
+ return null;
+ }
+ String best = null;
+ int bestDistance = Integer.MAX_VALUE;
+ int threshold = Math.max(2, name.length() / 3);
+ String lower = name.toLowerCase(Locale.ROOT);
+ for (String candidate : operations.keySet()) {
+ int d = editDistance(lower, candidate.toLowerCase(Locale.ROOT));
+ if (d > 0 && d <= threshold && (d < bestDistance || d ==
bestDistance && candidate.length() < best.length())) {
+ best = candidate;
+ bestDistance = d;
+ }
+ }
+ return best;
+ }
+
+ private static int editDistance(String a, String b) {
+ int[] prev = new int[b.length() + 1];
+ int[] cur = new int[b.length() + 1];
+ for (int j = 0; j <= b.length(); j++) {
+ prev[j] = j;
+ }
+ for (int i = 1; i <= a.length(); i++) {
+ cur[0] = i;
+ for (int j = 1; j <= b.length(); j++) {
+ int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
+ cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1),
prev[j - 1] + cost);
+ }
+ int[] t = prev;
+ prev = cur;
+ cur = t;
+ }
+ return prev[b.length()];
+ }
+
private static MethodInfo createSingleMethodInvocation(
Object pojo, Exchange exchange, List<MethodInfo> methods, boolean
emptyParameters, String methodName) {
MethodInfo methodInfo;
diff --git
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/MethodInfo.java
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/MethodInfo.java
index 0a5d009284cb..a3aac1320046 100644
---
a/components/camel-bean/src/main/java/org/apache/camel/component/bean/MethodInfo.java
+++
b/components/camel-bean/src/main/java/org/apache/camel/component/bean/MethodInfo.java
@@ -500,11 +500,41 @@ public class MethodInfo {
exchange, e);
} catch (IllegalArgumentException e) {
throw new RuntimeExchangeException(
- "IllegalArgumentException occurred invoking method: " +
mth + " using arguments: " + asList(arguments),
+ "IllegalArgumentException occurred invoking method: " +
mth + " using arguments: " + asList(arguments)
+ + argumentsHint(mth, arguments),
exchange, e);
}
}
+ /**
+ * A method with several parameters gets the body as the first and nothing
for the others: say that the arguments
+ * are written in the method name.
+ */
+ private static String argumentsHint(Method mth, Object[] arguments) {
+ Class<?>[] types = mth.getParameterTypes();
+ if (types.length < 2 || arguments == null) {
+ return "";
+ }
+ boolean nullForPrimitive = false;
+ for (int i = 0; i < types.length && i < arguments.length; i++) {
+ if (arguments[i] == null && types[i].isPrimitive()) {
+ nullForPrimitive = true;
+ }
+ }
+ if (!nullForPrimitive && arguments.length == types.length) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder(" (the method takes
").append(types.length)
+ .append(" parameters and only the message body is bound by
default: pass them in the method name, for"
+ + " example method: \"")
+ .append(mth.getName()).append("(${body}");
+ for (int i = 1; i < types.length; i++) {
+ sb.append(", ").append(types[i].isPrimitive() ||
Number.class.isAssignableFrom(types[i]) ? "1" : "'value'");
+ }
+ sb.append(")\"; ${header.name} and ${exchangeProperty.name} work
too)");
+ return sb.toString();
+ }
+
protected Expression[] createParameterExpressions() {
final int size = parameters.size();
LOG.trace("Creating parameters expression for {} parameters", size);
diff --git
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
index c30cb51cc795..7b517069b805 100644
---
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
+++
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
@@ -384,12 +384,48 @@ public class BeanExpression implements Expression,
Predicate {
exchange.setException(resultExchange.getException());
}
} catch (Exception e) {
- throw new RuntimeBeanExpressionException(exchange, beanName,
methodName, e);
+ throw new RuntimeBeanExpressionException(
+ exchange, describeBean(beanHolder, beanName, exchange),
methodHint(methodName, e), e);
}
return result;
}
+ /**
+ * ${body.toUpperCase} without parentheses binds the body as the parameter
of the one argument overload, which fails
+ * on the conversion; say that a method call needs parentheses.
+ */
+ private static String methodHint(String methodName, Throwable e) {
+ if (methodName != null && !methodName.contains("(") &&
!methodName.contains("[")) {
+ for (Throwable t = e; t != null; t = t.getCause()) {
+ String n = t.getClass().getSimpleName();
+ if (n.equals("NoTypeConversionAvailableException") ||
n.equals("InvalidPayloadException")) {
+ return methodName + " (a method call needs parentheses: "
+ methodName + "())";
+ }
+ }
+ }
+ return methodName;
+ }
+
+ /**
+ * The bean name, or for an OGNL on the message body (${body.foo()}) what
the body is, so the message does not say
+ * "on null".
+ */
+ private static String describeBean(BeanHolder beanHolder, String beanName,
Exchange exchange) {
+ if (beanName != null) {
+ return beanName;
+ }
+ try {
+ Object bean = beanHolder != null ? beanHolder.getBean(exchange) :
null;
+ if (bean != null) {
+ return "the message body of type " +
ObjectHelper.className(bean);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return "the message body";
+ }
+
/**
* To invoke a bean using a OGNL notation which denotes the chain of
methods to invoke.
* <p/>
@@ -491,11 +527,34 @@ public class BeanExpression implements Expression,
Predicate {
}
Object newResult = invokeBean(holder, beanName, methodName,
resultExchange);
if (resultExchange.getException() != null) {
- throw new RuntimeBeanExpressionException(exchange, beanName,
methodName, resultExchange.getException());
+ throw new RuntimeBeanExpressionException(
+ exchange, describeBean(holder, beanName, exchange),
+ keyHint(holder, exchange, methodName,
methodHint(methodName, resultExchange.getException())),
+ resultExchange.getException());
}
return newResult;
}
+ /**
+ * ${body.type} on a Map body looks for a method named type; a key is read
with ${body[type]}. Say so when the bean
+ * is a Map and the name is not a method call.
+ */
+ private static String keyHint(BeanHolder holder, Exchange exchange, String
methodName, String hint) {
+ if (methodName == null || methodName.contains("(") ||
methodName.contains("[")) {
+ return hint;
+ }
+ try {
+ Object bean = holder != null ? holder.getBean(exchange) : null;
+ if (bean instanceof Map) {
+ return hint + " (the value is a Map: a key is read with [" +
methodName + "], as in ${body[" + methodName
+ + "]}, not with ." + methodName + ")";
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return hint;
+ }
+
private Object lookupByKeyIfPresent(
String key, Object result, Exchange resultExchange, Exchange
exchange,
boolean nullSafe, String ognlPath, BeanHolder holder) {
diff --git
a/core/camel-api/src/main/java/org/apache/camel/NoSuchLanguageException.java
b/core/camel-api/src/main/java/org/apache/camel/NoSuchLanguageException.java
index fcdfcfe6b2e8..846593469527 100644
--- a/core/camel-api/src/main/java/org/apache/camel/NoSuchLanguageException.java
+++ b/core/camel-api/src/main/java/org/apache/camel/NoSuchLanguageException.java
@@ -31,7 +31,9 @@ public class NoSuchLanguageException extends
RuntimeCamelException {
* @param language the language name that could not be resolved
*/
public NoSuchLanguageException(String language) {
- super("No language could be found for: " +
Objects.requireNonNull(language, "language"));
+ super("No language could be found for: " +
Objects.requireNonNull(language, "language")
+ + (language.matches("[a-z0-9-]+")
+ ? " (a Camel language needs its dependency on the
classpath, e.g. camel-" + language + ")" : ""));
this.language = language;
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
index cd16855e5ae8..2b4bc0c726fc 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
@@ -469,11 +469,23 @@ public abstract class BaseSimpleParser {
return;
} else if (token == null) {
// use the previous index as that is where the problem is
+ if (expect == TokenType.functionEnd) {
+ throw new SimpleParserException(
+ "expected symbol " + expect + " but reached eol:
missing } to close the function", previousIndex);
+ }
throw new SimpleParserException("expected symbol " + expect + "
but reached eol", previousIndex);
} else {
// use the previous index as that is where the problem is
+ String hint = "";
+ if (expect == TokenType.functionEnd && token.getType().isEol()) {
+ hint = ": missing } to close the function";
+ } else if (expect == TokenType.whiteSpace &&
token.getType().getType() == TokenType.singleQuote) {
+ hint = ": a literal that contains a single quote must be
written with double quotes, e.g. \"it's\"";
+ } else if (expect == TokenType.whiteSpace) {
+ hint = ": operators and values must be separated by a space";
+ }
throw new SimpleParserException(
- "expected symbol " + expect + " but was " +
token.getType().getType(), previousIndex);
+ "expected symbol " + expect + " but was " +
token.getType().getType() + hint, previousIndex);
}
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
index 848e89f73465..eacaf07a7e8e 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
@@ -400,7 +400,8 @@ public class SimpleExpressionParser extends
BaseSimpleParser {
}
} else {
throw new SimpleParserException(
- "Other operator " + operatorType + " does not support
token " + token, token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Other",
operatorType, expression, token.getIndex()),
+ token.getIndex());
}
return true;
}
@@ -430,7 +431,8 @@ public class SimpleExpressionParser extends
BaseSimpleParser {
}
} else {
throw new SimpleParserException(
- "Chain operator " + operatorType + " does not support
token " + token, token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Chain",
operatorType, expression, token.getIndex()),
+ token.getIndex());
}
return true;
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
index 430874fc02d1..6b6ed4acdc08 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
@@ -147,7 +147,7 @@ public class SimplePredicateParser extends BaseSimpleParser
{
&& !token.getType().isEol()) {
// okay the symbol was not one of the above, so its not
supported
// use the previous index as that is where the problem is
- throw new SimpleParserException("Unexpected token " + token,
previousIndex);
+ throw new
SimpleParserException(SimpleSyntaxHints.unexpectedToken(expression,
previousIndex), previousIndex);
}
// take the next token
nextToken();
@@ -270,7 +270,7 @@ public class SimplePredicateParser extends BaseSimpleParser
{
if (startFunction.get()) {
// we have a start function, but no ending function
int index = evalIndex(lastFunction);
- throw new SimpleParserException("function has no ending token",
index);
+ throw new SimpleParserException("function has no ending token:
missing } to close ${...}", index);
}
}
@@ -739,7 +739,8 @@ public class SimplePredicateParser extends BaseSimpleParser
{
}
} else {
throw new SimpleParserException(
- "Binary operator " + operatorType + " does not support
token " + token, token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Binary",
operatorType, expression, token.getIndex()),
+ token.getIndex());
}
return true;
}
@@ -766,7 +767,7 @@ public class SimplePredicateParser extends BaseSimpleParser
{
}
} else {
throw new SimpleParserException(
- "Ternary operator does not support token " + token,
token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Ternary", "?:",
expression, token.getIndex()), token.getIndex());
}
return true;
}
@@ -796,7 +797,8 @@ public class SimplePredicateParser extends BaseSimpleParser
{
}
} else {
throw new SimpleParserException(
- "Other operator " + operatorType + " does not support
token " + token, token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Other",
operatorType, expression, token.getIndex()),
+ token.getIndex());
}
return true;
}
@@ -826,7 +828,8 @@ public class SimplePredicateParser extends BaseSimpleParser
{
}
} else {
throw new SimpleParserException(
- "Chain operator " + operatorType + " does not support
token " + token, token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Chain",
operatorType, expression, token.getIndex()),
+ token.getIndex());
}
return true;
}
@@ -856,7 +859,8 @@ public class SimplePredicateParser extends BaseSimpleParser
{
}
} else {
throw new SimpleParserException(
- "Logical operator " + operatorType + " does not
support token " + token, token.getIndex());
+ SimpleSyntaxHints.unsupportedOperand("Logical",
operatorType, expression, token.getIndex()),
+ token.getIndex());
}
return true;
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
new file mode 100644
index 000000000000..e771f59f6bab
--- /dev/null
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.language.simple;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Hints that turn a Simple parser error into a message that says what to
write (CAMEL-24703). Every consumer of the
+ * parser (routes at startup, the catalog validator, camel validate, the TUI
and the MCP tools) shows the message, so a
+ * hint here reaches humans and AI agents alike.
+ */
+public final class SimpleSyntaxHints {
+
+ public static final String OPERATORS = "==, =~, !=, <, <=, >, >=,
contains, !contains, ~~, !~~, regex, !regex, in, !in, "
+ + "is, !is, range, !range,
startsWith, endsWith, && and ||";
+
+ /** The value forms an operator accepts on its right hand side. */
+ public static final String VALUE_FORMS = "a quoted literal 'x', a number,
true, false, null, or a function ${...}";
+
+ /** Function names a model is likely to write, for the did-you-mean
suggestion. */
+ static final List<String> FUNCTIONS = List.of("body", "bodyAs",
"mandatoryBodyAs", "bodyOneLine", "prettyBody",
+ "originalBody", "header", "headerAs", "headers",
"exchangeProperty", "exchangePropertyAs",
+ "exchangeProperties", "variable", "variableAs", "variables",
"exception", "exchange", "camelContext",
+ "camelId", "routeId", "routeGroup", "stepId", "id",
"messageTimestamp", "threadName", "threadId",
+ "hostname", "date", "date-with-timezone", "random", "skip",
"collate", "join", "sum", "avg", "min", "max",
+ "replace", "substring", "substringBefore", "substringAfter",
"substringBetween", "contains", "pad",
+ "concat", "val", "length", "empty", "newEmpty", "iif", "hash",
"convertTo", "throwException", "assert",
+ "load", "uuid", "env", "sys", "ref", "bean", "properties",
"propertiesExist", "type", "messageAs",
+ "messageHistory", "pretty", "toJson", "toPrettyJson", "jq",
"jsonpath", "xpath", "simpleJsonpath",
+ "function", "list", "map", "range", "split", "sort", "forEach",
"filter", "listAdd", "listRemove",
+ "mapAdd", "mapRemove", "file", "null");
+
+ /** Names from older Camel versions or other languages that a model still
writes. */
+ static final Map<String, String> ALIASES = Map.ofEntries(
+ Map.entry("property", "exchangeProperty"),
+ Map.entry("in.body", "body"),
+ Map.entry("out.body", "body"),
+ Map.entry("in.header", "header"),
+ Map.entry("out.header", "header"),
+ Map.entry("in.headers", "headers"),
+ Map.entry("var", "variable"),
+ Map.entry("prop", "exchangeProperty"),
+ Map.entry("json", "jsonpath"),
+ Map.entry("upper", "bodyAs(String).toUpperCase()"),
+ Map.entry("lower", "bodyAs(String).toLowerCase()"),
+ Map.entry("trim", "bodyAs(String).trim()"),
+ Map.entry("padding", "pad"),
+ Map.entry("now", "date:now:yyyy-MM-dd'T'HH:mm:ss"),
+ Map.entry("timestamp", "messageTimestamp"),
+ Map.entry("size", "length"),
+ Map.entry("count", "length"));
+
+ private static final String[] OPERATOR_WORDS = {
+ "==", "!=", ">=", "<=", ">", "<", "=~", "!=~", "~~", "!~~",
"contains",
+ "!contains", "regex", "!regex", "in", "!in", "is", "!is", "range",
"!range", "startsWith", "endsWith", "&&", "||" };
+
+ private SimpleSyntaxHints() {
+ }
+
+ /** The whole word (up to whitespace on both sides) around the index in
the expression. */
+ public static String wordAt(String expression, int index) {
+ if (expression == null || expression.isEmpty()) {
+ return "";
+ }
+ int start = Math.min(Math.max(index, 0), expression.length());
+ int end = start;
+ while (start > 0 && !Character.isWhitespace(expression.charAt(start -
1))) {
+ start--;
+ }
+ while (end < expression.length() &&
!Character.isWhitespace(expression.charAt(end))) {
+ end++;
+ }
+ return expression.substring(start, end);
+ }
+
+ /** The message for a token the grammar does not know at the given index.
*/
+ public static String unexpectedToken(String expression, int index) {
+ String word = wordAt(expression, index);
+ if (word.isEmpty()) {
+ return "Unexpected token at location " + index;
+ }
+ String lower = word.toLowerCase(Locale.ROOT);
+ switch (lower) {
+ case "=":
+ case "===":
+ case "=<":
+ case "=>":
+ return "Unknown operator " + word + ": did you mean ==?
Operators are " + OPERATORS;
+ case "<>":
+ case "=!=":
+ case "!==":
+ case "~=":
+ return "Unknown operator " + word + ": did you mean !=?
Operators are " + OPERATORS;
+ case "and":
+ case "&":
+ return "Unknown operator " + word + ": use && for and, and ||
for or";
+ case "or":
+ case "|":
+ return "Unknown operator " + word + ": use || for or, and &&
for and";
+ case "not":
+ case "!":
+ return "Unknown operator " + word + ": negate the operator
instead, e.g. != or !contains";
+ default:
+ }
+ String name = functionName(word);
+ if (isKnownFunction(name)) {
+ String rewrite = expression.replace(word, "${" + word + "}");
+ return "Unexpected token " + word + ": text outside ${...} is a
literal, functions are written as ${body}, "
+ + "${header.name}; did you mean " + rewrite + "?";
+ }
+ return "Unexpected token " + word + ": text outside ${...} must be " +
VALUE_FORMS + ", or an operator ("
+ + OPERATORS + ")";
+ }
+
+ /** The message when an operator has no usable value next to it. */
+ public static String unsupportedOperand(String kind, Object operator,
String expression, int index) {
+ String word = wordAt(expression, index);
+ if ("Logical".equals(kind)) {
+ return kind + " operator " + operator + " needs a predicate on the
right hand side, e.g. ${header.foo} == 'bar'"
+ + (word.isEmpty() ? "" : "; was: " + word);
+ }
+ if (word.isEmpty() || word.equals(String.valueOf(operator))) {
+ return kind + " operator " + operator + " needs a value on the
right hand side: " + VALUE_FORMS;
+ }
+ return kind + " operator " + operator + " does not accept " + word + "
on the right hand side: write it as "
+ + VALUE_FORMS + (isKnownFunction(functionName(word)) ? ", e.g.
${" + word + "}" : ", e.g. '" + word + "'");
+ }
+
+ /**
+ * When an operator is written inside the function (${body == 'x'}), the
rewrite with the operator outside, else
+ * null.
+ */
+ public static String operatorsOutside(String function) {
+ int best = -1;
+ String bestOp = null;
+ for (String op : OPERATOR_WORDS) {
+ int i = function.indexOf(" " + op + " ");
+ if (i > 0 && (best < 0 || i < best)) {
+ best = i;
+ bestOp = op;
+ }
+ }
+ if (bestOp == null) {
+ return null;
+ }
+ String head = function.substring(0, best);
+ if (head.contains("'") || head.contains("\"")) {
+ return null;
+ }
+ int open = 0;
+ for (int i = 0; i < head.length(); i++) {
+ if (head.charAt(i) == '(') {
+ open++;
+ } else if (head.charAt(i) == ')') {
+ open--;
+ }
+ }
+ if (open > 0) {
+ // the operator is inside an argument list that may hold a
predicate (iif, filter, forEach)
+ return null;
+ }
+ return "${" + head + "}" + function.substring(best);
+ }
+
+ /** A hint for an unknown function, or null. */
+ public static String unknownFunction(String function) {
+ if (function == null) {
+ return null;
+ }
+ String trimmed = function.trim();
+ if (!trimmed.equals(function)) {
+ return "remove the spaces: ${" + trimmed + "}";
+ }
+ String rewrite = operatorsOutside(function);
+ if (rewrite != null) {
+ return "operators go outside the function: " + rewrite;
+ }
+ String bare = function.contains("(") ? function.substring(0,
function.indexOf('(')) : function;
+ bare = bare.contains(".") ? bare.substring(0, bare.indexOf('.')) :
bare;
+ if (bare.equals("simple")) {
+ // ${simple} in a log message or a simple: expression: the text is
already simple
+ return "simple is the language, not a function: the text is
already a simple expression, write the values"
+ + " with ${body}, ${header.name}, ${date:now:HH:mm:ss} and
leave the rest as plain text";
+ }
+ if
(bare.matches("groovy|jsonpath|xpath|xquery|jq|mvel|ognl|spel|js|python|java|constant|tokenize|method"))
{
+ return bare + " is a language, not a simple function: another
language cannot be nested inside ${...}; write"
+ + " the expression with its own key, for example " + bare +
": \"...\"";
+ }
+ if (function.matches(".*\\S\\s+[-+*/%]\\s+\\S.*")) {
+ // ${exchangeCounter % 3}, ${header.total * 2}: there is no
arithmetic in simple
+ return "simple has no arithmetic operators (+ - * / %): compute
the value in a groovy expression"
+ + " (groovy: \"...\") or in a bean, and use the result
here";
+ }
+ String name = functionName(function);
+ String rest = function.substring(name.length());
+ String alias = ALIASES.get(name);
+ if (alias == null && name.contains(".")) {
+ alias = ALIASES.get(name.substring(0, name.indexOf('.')));
+ if (alias != null) {
+ alias = alias + name.substring(name.indexOf('.'));
+ }
+ }
+ if (alias != null) {
+ return "did you mean ${" + alias + rest + "}?";
+ }
+ for (String f : FUNCTIONS) {
+ if (f.equalsIgnoreCase(name) && !f.equals(name)) {
+ return "function names are case sensitive: ${" + f + rest +
"}";
+ }
+ }
+ String closest = closest(name);
+ if (closest != null) {
+ return "did you mean ${" + closest + rest + "}?";
+ }
+ return "the functions are documented on the simple language page
(functions)";
+ }
+
+ static String functionName(String function) {
+ int end = 0;
+ while (end < function.length()) {
+ char ch = function.charAt(end);
+ if (ch == '(' || ch == ':' || ch == '[' || ch == '?' ||
Character.isWhitespace(ch)) {
+ break;
+ }
+ if (ch == '.' && !function.startsWith("in.", 0) &&
!function.startsWith("out.", 0)
+ && !function.startsWith("date-with", 0)) {
+ // header.foo and exchangeProperty.foo: the name is the part
before the dot; in.body keeps its dot
+ break;
+ }
+ end++;
+ }
+ return function.substring(0, end);
+ }
+
+ static boolean isKnownFunction(String name) {
+ return !name.isEmpty() && (FUNCTIONS.contains(name) ||
ALIASES.containsKey(name));
+ }
+
+ static String closest(String name) {
+ if (name.length() < 3) {
+ return null;
+ }
+ // bodyxxx, headerz: a known function name followed by something that
is not OGNL
+ String prefix = null;
+ for (String f : FUNCTIONS) {
+ if (f.length() >= 3 && name.startsWith(f) && (prefix == null ||
f.length() > prefix.length())) {
+ prefix = f;
+ }
+ }
+ if (prefix != null) {
+ return prefix;
+ }
+ String best = null;
+ int bestDistance = Integer.MAX_VALUE;
+ int threshold = Math.max(2, name.length() / 3);
+ String lower = name.toLowerCase(Locale.ROOT);
+ for (String f : FUNCTIONS) {
+ int d = distance(lower, f.toLowerCase(Locale.ROOT));
+ if (d <= threshold && (d < bestDistance || d == bestDistance &&
f.length() < best.length())) {
+ best = f;
+ bestDistance = d;
+ }
+ }
+ return best;
+ }
+
+ static int distance(String a, String b) {
+ int[] prev = new int[b.length() + 1];
+ int[] cur = new int[b.length() + 1];
+ for (int j = 0; j <= b.length(); j++) {
+ prev[j] = j;
+ }
+ for (int i = 1; i <= a.length(); i++) {
+ cur[0] = i;
+ for (int j = 1; j <= b.length(); j++) {
+ int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
+ cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1),
prev[j - 1] + cost);
+ }
+ int[] t = prev;
+ prev = cur;
+ cur = t;
+ }
+ return prev[b.length()];
+ }
+}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
index 9c36cfcd4093..c120eabf3082 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
@@ -23,6 +23,7 @@ import org.apache.camel.Expression;
import org.apache.camel.language.simple.FileExpressionBuilder;
import org.apache.camel.language.simple.SimpleFunctionDispatcher;
import org.apache.camel.language.simple.SimpleFunctionHelper;
+import org.apache.camel.language.simple.SimpleSyntaxHints;
import org.apache.camel.language.simple.functions.DirectFunctionFactory;
import org.apache.camel.language.simple.types.SimpleParserException;
import org.apache.camel.language.simple.types.SimpleToken;
@@ -98,6 +99,13 @@ public class SimpleFunctionExpression extends
LiteralExpression {
private static final DirectFunctionFactory DIRECT_FACTORY = new
DirectFunctionFactory();
private Expression doCreateSimpleExpression(CamelContext camelContext,
String function, boolean strict) {
+ if (strict) {
+ // ${body == 'x'}: the operator belongs outside the function
(CAMEL-24703)
+ String rewrite = SimpleSyntaxHints.operatorsOutside(function);
+ if (rewrite != null) {
+ throw new SimpleParserException("Operators go outside the
function: " + rewrite, token.getIndex());
+ }
+ }
// return the function directly if we can create function without
analyzing the prefix
Expression answer = DIRECT_FACTORY.createFunction(camelContext,
function, token.getIndex());
if (answer != null) {
@@ -147,7 +155,10 @@ public class SimpleFunctionExpression extends
LiteralExpression {
}
if (strict) {
- throw new SimpleParserException("Unknown function: " + function,
token.getIndex());
+ String hint = SimpleSyntaxHints.unknownFunction(function);
+ throw new SimpleParserException(
+ "Unknown function: " + function + (hint != null ? " (" +
hint + ")" : ""),
+ token.getIndex());
} else {
return null;
}
@@ -184,7 +195,12 @@ public class SimpleFunctionExpression extends
LiteralExpression {
return FileExpressionBuilder.fileLastModifiedExpression();
}
if (strict) {
- throw new SimpleParserException("Unknown file language syntax: " +
remainder, token.getIndex());
+ throw new SimpleParserException(
+ "Unknown file language syntax: " + remainder + " (the
file: functions describe the file being consumed:"
+ + " ${file:name}, ${file:size},
${file:parent}, ${file:absolute.path};"
+ + " they do not read a file. To
read a file into the body use the poll"
+ + " EIP with a file: endpoint)",
+ token.getIndex());
}
return null;
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/BodyFunctionFactory.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/BodyFunctionFactory.java
index b826f2ef4ff8..ad267e78d056 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/BodyFunctionFactory.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/BodyFunctionFactory.java
@@ -100,8 +100,11 @@ public final class BodyFunctionFactory implements
SimpleLanguageFunctionFactory
}
if (remainder != null) {
boolean ognlStart = remainder.startsWith(".") ||
remainder.startsWith("?") || remainder.startsWith("[");
- boolean invalid = !ognlStart ||
OgnlHelper.isInvalidValidOgnlExpression(remainder);
- if (invalid) {
+ if (!ognlStart) {
+ // bodyy or bodyX: not a body OGNL at all, let the caller
report an unknown function with a hint
+ return null;
+ }
+ if (OgnlHelper.isInvalidValidOgnlExpression(remainder)) {
throw new SimpleParserException("Valid syntax: ${body.OGNL}
was: " + function, index);
}
return OgnlExpressionBuilder.bodyOgnlExpression(remainder);
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/ExchangeFunctionFactory.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/ExchangeFunctionFactory.java
index a3a78028b0a1..6b2cdd98bdcd 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/ExchangeFunctionFactory.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/ExchangeFunctionFactory.java
@@ -82,7 +82,8 @@ public final class ExchangeFunctionFactory implements
SimpleLanguageFunctionFact
// exchange OGNL
remainder = ifStartsWithReturnRemainder("exchange", function);
- if (remainder != null) {
+ if (remainder != null && (remainder.startsWith(".") ||
remainder.startsWith("?") || remainder.startsWith("["))) {
+ // only ${exchange.OGNL}: ${exchangeCounter} is not an exchange
OGNL but an unknown function
boolean invalid =
OgnlHelper.isInvalidValidOgnlExpression(remainder);
if (invalid) {
throw new SimpleParserException("Valid syntax:
${exchange.OGNL} was: " + function, index);
diff --git
a/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanNoMatchingMethodHintTest.java
b/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanNoMatchingMethodHintTest.java
new file mode 100644
index 000000000000..093c91d9fa77
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanNoMatchingMethodHintTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.bean;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-24703: a bean with several methods and no method name must say which
methods it has and how to choose one.
+ */
+public class BeanNoMatchingMethodHintTest extends ContextTestSupport {
+
+ @Test
+ public void testHintNamesTheMethods() {
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () -> template.sendBody("direct:start", "hi"));
+ String msg = e.getCause().getMessage();
+ assertTrue(msg.contains("No method invocation could be created"), msg);
+ assertTrue(msg.contains("the bean has the methods addObjects,
getLeakedObjectCount"), msg);
+ assertTrue(msg.contains("method: <name>"), msg);
+ }
+
+ @Test
+ public void testSeveralParametersHint() {
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () -> template.sendBody("direct:two", "hi"));
+ String msg = e.getCause().getMessage();
+ assertTrue(msg.contains("the method takes 2 parameters and only the
message body is bound by default"), msg);
+ assertTrue(msg.contains("method: \"format(${body}, 1)\""), msg);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start").bean(new TwoMethods());
+ from("direct:two").bean(new TwoParams(), "format");
+ }
+ };
+ }
+
+ public static class TwoParams {
+ public String format(String body, int times) {
+ return body.repeat(times);
+ }
+ }
+
+ public static class TwoMethods {
+ public int addObjects() {
+ return 1;
+ }
+
+ public int getLeakedObjectCount() {
+ return 2;
+ }
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanParameterInvalidValueTest.java
b/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanParameterInvalidValueTest.java
index 38f9c037002d..7c417e5caef2 100644
---
a/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanParameterInvalidValueTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/component/bean/BeanParameterInvalidValueTest.java
@@ -86,7 +86,7 @@ public class BeanParameterInvalidValueTest extends
ContextTestSupport {
"Should have thrown exception");
ExpressionEvaluationException cause =
assertIsInstanceOf(ExpressionEvaluationException.class, e.getCause());
- assertTrue(cause.getCause().getMessage().startsWith("Unknown function:
xxx at location 0"));
+ assertTrue(cause.getCause().getMessage().startsWith("Unknown function:
xxx"));
assertMockEndpointsSatisfied();
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/FileLanguageTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/FileLanguageTest.java
index f8765cbee59c..7a5382926057 100644
---
a/core/camel-core/src/test/java/org/apache/camel/language/FileLanguageTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/language/FileLanguageTest.java
@@ -77,7 +77,8 @@ public class FileLanguageTest extends LanguageTestSupport {
() -> assertExpression("${file:onlyName}", file.getName()),
"Should have thrown exception");
- assertTrue(e.getMessage().startsWith("Unknown file language syntax:
onlyName at location 0"));
+ assertTrue(e.getMessage().startsWith("Unknown file language syntax:
onlyName"), e.getMessage());
+ assertTrue(e.getMessage().contains("at location 0"), e.getMessage());
}
@Test
@@ -230,19 +231,19 @@ public class FileLanguageTest extends LanguageTestSupport
{
() -> assertExpression("${file.name}", ""),
"Should have thrown an exception");
- assertTrue(e1.getMessage().startsWith("Unknown function: file.name at
location 0"));
+ assertTrue(e1.getMessage().startsWith("Unknown function: file.name"));
ExpressionIllegalSyntaxException e2 =
assertThrows(ExpressionIllegalSyntaxException.class,
() -> assertExpression("hey ${xxx} how are you?", ""),
"Should have thrown an exception");
- assertTrue(e2.getMessage().startsWith("Unknown function: xxx at
location 4"));
+ assertTrue(e2.getMessage().startsWith("Unknown function: xxx"));
ExpressionIllegalSyntaxException e3 =
assertThrows(ExpressionIllegalSyntaxException.class,
() -> assertExpression("${xxx}", ""),
"Should have thrown an exception");
- assertTrue(e3.getMessage().startsWith("Unknown function: xxx at
location 0"));
+ assertTrue(e3.getMessage().startsWith("Unknown function: xxx"));
}
@Test
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/NoSuchLanguageTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/NoSuchLanguageTest.java
index 5a72795c62cd..f1fe9af8e574 100644
---
a/core/camel-core/src/test/java/org/apache/camel/language/NoSuchLanguageTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/language/NoSuchLanguageTest.java
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class NoSuchLanguageTest extends LanguageTestSupport {
@@ -31,7 +32,7 @@ public class NoSuchLanguageTest extends LanguageTestSupport {
() -> assertPredicate("foo"),
"Should have thrown an exception");
- assertEquals("No language could be found for: unknown",
e.getMessage());
+ assertTrue(e.getMessage().startsWith("No language could be found for:
unknown"), e.getMessage());
assertEquals("unknown", e.getLanguage());
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
new file mode 100644
index 000000000000..73ce4fd93499
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.language.simple;
+
+import org.apache.camel.ExchangeTestSupport;
+import org.apache.camel.language.simple.types.SimpleIllegalSyntaxException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * CAMEL-24703: the parser messages say what to write, for the mistakes people
and AI agents make most.
+ */
+public class SimpleSyntaxHintsTest extends ExchangeTestSupport {
+
+ private String predicateError(String text) {
+ SimplePredicateParser parser = new SimplePredicateParser(context,
text, true, null);
+ return assertThrows(SimpleIllegalSyntaxException.class,
parser::parsePredicate).getMessage();
+ }
+
+ private String expressionError(String text) {
+ SimpleExpressionParser parser = new SimpleExpressionParser(context,
text, true, null);
+ return assertThrows(SimpleIllegalSyntaxException.class,
parser::parseExpression).getMessage();
+ }
+
+ @Test
+ public void testFunctionWithoutDollarBraces() {
+ assertThat(predicateError("body == 'Hello World'"))
+ .contains("text outside ${...} is a literal")
+ .contains("did you mean ${body} == 'Hello World'?");
+ assertThat(predicateError("header.foo == 'bar'")).contains("did you
mean ${header.foo} == 'bar'?");
+ }
+
+ @Test
+ public void testOperatorInsideFunction() {
+ assertThat(predicateError("${body == 'x'}")).contains("Operators go
outside the function: ${body} == 'x'");
+ }
+
+ @Test
+ public void testUnquotedValue() {
+ assertThat(predicateError("${body} == x"))
+ .contains("does not accept x on the right hand side")
+ .contains("a quoted literal 'x'")
+ .doesNotContain("token null");
+ assertThat(predicateError("${body} contains x")).contains("does not
accept x on the right hand side");
+ }
+
+ @Test
+ public void testUnknownOperators() {
+ assertThat(predicateError("${body} = 'x'")).contains("Unknown operator
=: did you mean ==?");
+ assertThat(predicateError("${body} == 'x' and ${header.y} ==
1")).contains("use && for and");
+ assertThat(predicateError("${body} == 'x' || ")).contains("needs a
predicate on the right hand side");
+ }
+
+ @Test
+ public void testQuotes() {
+ assertThat(predicateError("${body} == 'it''s'")).contains("double
quotes");
+ }
+
+ @Test
+ public void testMissingClosingBrace() {
+ assertThat(expressionError("${header.foo")).contains("missing } to
close");
+ assertThat(expressionError("${bodyAs(String)")).contains("missing } to
close");
+ }
+
+ @Test
+ public void testUnknownFunctionSuggestions() {
+ assertThat(expressionError("${padding(3)}")).contains("Unknown
function: padding(3)")
+ .contains("did you mean ${pad(3)}?");
+ assertThat(expressionError("${property.foo}")).contains("did you mean
${exchangeProperty.foo}?");
+ assertThat(expressionError("${Body}")).contains("case sensitive:
${body}");
+ assertThat(expressionError("${ body }")).contains("remove the spaces:
${body}");
+ assertThat(expressionError("${bodyy}")).contains("did you mean
${body}?");
+ }
+
+ @Test
+ public void testOperatorAfterOgnlMethod() {
+ assertThat(predicateError("${body.length() > 3}")).contains("Operators
go outside the function: ${body.length()} > 3");
+ }
+
+ @Test
+ public void testOgnlRuntimeMessages() {
+ exchange.getIn().setBody("hello");
+ Exception e = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${body.lenght()}").evaluate(exchange,
String.class));
+ assertThat(e.getMessage())
+ .contains("on the message body of type java.lang.String")
+ .contains("did you mean length()?")
+ .doesNotContain("on null");
+ }
+
+ @Test
+ public void testOgnlDotOnAMapSaysToUseAKey() {
+ exchange.getIn().setBody(new
java.util.LinkedHashMap<>(java.util.Map.of("type", "order")));
+ Exception e = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${body.type}").evaluate(exchange,
+ String.class));
+ assertThat(e.getMessage()).contains("the value is a Map: a key is read
with [type], as in ${body[type]}");
+ assertEquals("order",
context.resolveLanguage("simple").createExpression("${body[type]}").evaluate(exchange,
+ String.class));
+ }
+
+ @Test
+ public void testArithmeticInAFunctionSaysThereIsNone() {
+ Exception e = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${exchangeCounter % 3}"));
+ assertThat(e.getMessage()).contains("Unknown function: exchangeCounter
% 3")
+ .contains("simple has no arithmetic operators");
+ }
+
+ @Test
+ public void testLanguageNameAsAFunctionIsNamed() {
+ Exception e = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${simple}[${date:now:HH:mm}]
size ${body.length}"));
+ assertThat(e.getMessage()).contains("Unknown function: simple")
+ .contains("simple is the language, not a function");
+ Exception g = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${groovy(body.size())}"));
+ assertThat(g.getMessage()).contains("groovy is a language, not a
simple function").contains("groovy: \"...\"");
+ }
+
+ @Test
+ public void testOgnlMethodWithoutParentheses() {
+ exchange.getIn().setBody("hello");
+ Exception e = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${body.toUpperCase}").evaluate(exchange,
+ String.class));
+ assertThat(e.getMessage()).contains("a method call needs parentheses:
toUpperCase()");
+ }
+
+ @Test
+ public void testMissingLanguageNamesTheDependency() {
+ Exception e = assertThrows(Exception.class, () ->
context.resolveLanguage("cheese"));
+ assertThat(e.getMessage()).contains("No language could be found for:
cheese").contains("camel-cheese");
+ }
+
+ @Test
+ public void testFileFunctionDoesNotReadAFile() {
+ assertThat(expressionError("${file:src/main/resources/input.xml}"))
+ .contains("Unknown file language syntax")
+ .contains("they do not read a file")
+ .contains("poll EIP");
+ }
+
+ @Test
+ public void testHelpers() {
+ assertThat(SimpleSyntaxHints.wordAt("${body} == x",
12)).isEqualTo("x");
+ assertThat(SimpleSyntaxHints.wordAt("${body} == 'x' and ${header.y} ==
1", 15)).isEqualTo("and");
+
assertThat(SimpleSyntaxHints.functionName("header.foo")).isEqualTo("header");
+
assertThat(SimpleSyntaxHints.functionName("bodyAs(String)")).isEqualTo("bodyAs");
+
assertThat(SimpleSyntaxHints.functionName("in.body")).isEqualTo("in.body");
+ assertThat(SimpleSyntaxHints.operatorsOutside("iif(${body} == 'x',
'a', 'b')")).isNull();
+ assertThat(SimpleSyntaxHints.operatorsOutside("body ==
'x'")).isEqualTo("${body} == 'x'");
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
index 9e14b4bf6da3..97ce538fc685 100644
---
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
@@ -938,13 +938,13 @@ public class SimpleTest extends LanguageTestSupport {
() -> assertExpression("hey ${xxx} how are you?", ""),
"Should have thrown an exception");
- assertTrue(e1.getMessage().startsWith("Unknown function: xxx at
location 4"));
+ assertTrue(e1.getMessage().startsWith("Unknown function: xxx"));
ExpressionIllegalSyntaxException e2 =
assertThrows(ExpressionIllegalSyntaxException.class,
() -> assertExpression("${xxx}", ""),
"Should have thrown an exception");
- assertTrue(e2.getMessage().startsWith("Unknown function: xxx at
location 0"));
+ assertTrue(e2.getMessage().startsWith("Unknown function: xxx"));
ExpressionIllegalSyntaxException e3 =
assertThrows(ExpressionIllegalSyntaxException.class,
() -> assertExpression("${bodyAs(xxx}", ""),
diff --git
a/core/camel-management/src/test/java/org/apache/camel/management/BacklogDebuggerTest.java
b/core/camel-management/src/test/java/org/apache/camel/management/BacklogDebuggerTest.java
index 458ff9ab4da5..c101b3706580 100644
---
a/core/camel-management/src/test/java/org/apache/camel/management/BacklogDebuggerTest.java
+++
b/core/camel-management/src/test/java/org/apache/camel/management/BacklogDebuggerTest.java
@@ -448,7 +448,7 @@ public class BacklogDebuggerTest extends
ManagementTestSupport {
// validate conditional breakpoint (mistake on purpose)
Object out = mbeanServer.invoke(on, "validateConditionalBreakpoint",
new Object[] { "unknown", "${body contains 'Camel'" }, new
String[] { "java.lang.String", "java.lang.String" });
- assertEquals("No language could be found for: unknown", out);
+ assertTrue(String.valueOf(out).startsWith("No language could be found
for: unknown"), String.valueOf(out));
// validate conditional breakpoint (mistake on purpose)
out = mbeanServer.invoke(on, "validateConditionalBreakpoint", new
Object[] { "simple", "${body contains 'Camel'" },