This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch feature/CAMEL-25208-code-assist
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 2cf10f651afcab66a47d78735be2da66039c3499
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 30 22:49:17 2026 +0200

    CAMEL-25208: camel-jbang - Camel checks of Java and XML DSL routes on the 
model
    
    A model-based layer for any tool (TUI, MCP, CLI): RouteModel reads a Java
    (LwJavaParser) or XML (ModelParser) DSL source into the Camel model with
    lines, RouteNodes lists its steps, endpoints and expressions by line, and
    ModelChecks runs the checks the YAML DSL has on that model: endpoint uris
    against the catalog, simple syntax, a to that needs toD, a producer-only
    component as from, several endpoints in one uri, direct/seda endpoints no
    route consumes. RouteAssist is the entry point, shaped like the operations
    of a language server (diagnostics, nodes by line) without the protocol.
    
    The per-uri and per-expression checks of EndpointChecks and SimpleChecks
    are shared with the YAML checks. Running them over the Camel examples and
    Camel's own tests found false positives that the YAML checks have as well,
    fixed for both: exchangePattern on a to (SendProcessor reads it from the
    uri), kamelet parameters, map options written name[key], simple
    resource: scripts, application classes and custom functions.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/ai/EndpointChecks.java | 125 +++++--
 .../dsl/jbang/core/commands/ai/ModelChecks.java    | 238 +++++++++++++
 .../dsl/jbang/core/commands/ai/RouteAssist.java    | 181 ++++++++++
 .../dsl/jbang/core/commands/ai/RouteModel.java     | 278 +++++++++++++++
 .../dsl/jbang/core/commands/ai/RouteNodes.java     | 305 ++++++++++++++++
 .../dsl/jbang/core/commands/ai/SimpleChecks.java   |  80 +++--
 .../jbang/core/commands/ai/RouteAssistTest.java    | 394 +++++++++++++++++++++
 7 files changed, 1544 insertions(+), 57 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index 0163b9bb44a4..2e4c22d6af10 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -30,6 +30,7 @@ import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.catalog.EndpointValidationResult;
 import org.apache.camel.catalog.RuntimeProvider;
+import org.apache.camel.util.StringHelper;
 
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.YAML_URI_PATTERN;
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.countLeadingSpaces;
@@ -218,31 +219,50 @@ final class EndpointChecks {
                 }
             }
 
-            String fullUri = uriBuilder.toString();
-            try {
-                EndpointValidationResult result
-                        = catalog.validateEndpointProperties(fullUri, false, 
consumerOnly, producerOnly);
-                String scheme = fullUri.contains(":") ? fullUri.substring(0, 
fullUri.indexOf(':')) : fullUri;
-                if (result.getUnknownComponent() != null) {
-                    // a warning to the catalog, an error when the runtime is 
what lacks the component
-                    String missing = missingInRuntime(catalog, scheme);
-                    if (missing != null) {
-                        errors.add(linePrefix(i) + missing);
-                    }
-                }
-                if (!result.isSuccess()) {
-                    collectEndpointErrors(errors, result, scheme, i, 
optionLineMap);
+            checkUri(errors, catalog, uriBuilder.toString(), line, i, 
optionLineMap, eipName, consumerOnly, producerOnly,
+                    false);
+        }
+        return errors;
+    }
+
+    /**
+     * The checks of one endpoint uri, whatever the DSL it is written in: the 
options against the catalog, then the
+     * mistakes the catalog does not see.
+     *
+     * @param fullUri       the uri with all its options
+     * @param rawLine       the source line of the uri (the sql check reads a 
statement with spaces from it)
+     * @param lineIdx       the index of the line of the uri
+     * @param optionLineMap the index of the line of each option written on a 
line of its own
+     * @param eipName       the EIP of the uri: from, to, toD...
+     * @param uriForm       whether the options are written in the uri (Java 
and XML) rather than under parameters
+     *                      (YAML), for the fix the messages suggest
+     */
+    static void checkUri(
+            List<String> errors, CamelCatalog catalog, String fullUri, String 
rawLine, int lineIdx,
+            Map<String, Integer> optionLineMap, String eipName, boolean 
consumerOnly, boolean producerOnly,
+            boolean uriForm) {
+        try {
+            EndpointValidationResult result
+                    = catalog.validateEndpointProperties(fullUri, false, 
consumerOnly, producerOnly);
+            String scheme = fullUri.contains(":") ? fullUri.substring(0, 
fullUri.indexOf(':')) : fullUri;
+            if (result.getUnknownComponent() != null) {
+                // a warning to the catalog, an error when the runtime is what 
lacks the component
+                String missing = missingInRuntime(catalog, scheme);
+                if (missing != null) {
+                    errors.add(linePrefix(lineIdx) + missing);
                 }
-                checkRegexOptions(errors, fullUri, i, optionLineMap);
-                checkDynamicDirectory(errors, fullUri, i, eipName);
-                checkSimplePlaceholders(errors, fullUri, i, optionLineMap, 
eipName);
-                checkRequiredPathOptions(errors, fullUri, catalog, i, eipName);
-                checkSqlNamedParameters(errors, fullUri, line, i, 
optionLineMap);
-            } catch (Exception e) {
-                // ignore validation errors
             }
+            if (!result.isSuccess()) {
+                collectEndpointErrors(errors, result, scheme, catalog, 
lineIdx, optionLineMap);
+            }
+            checkRegexOptions(errors, fullUri, lineIdx, optionLineMap);
+            checkDynamicDirectory(errors, fullUri, lineIdx, eipName);
+            checkSimplePlaceholders(errors, fullUri, lineIdx, optionLineMap, 
eipName, uriForm);
+            checkRequiredPathOptions(errors, fullUri, catalog, lineIdx, 
eipName, uriForm);
+            checkSqlNamedParameters(errors, fullUri, rawLine, lineIdx, 
optionLineMap);
+        } catch (Exception e) {
+            // ignore validation errors
         }
-        return errors;
     }
 
     private static volatile CamelCatalog defaultCatalog;
@@ -477,7 +497,8 @@ final class EndpointChecks {
      * the uri as Simple first and are left alone.
      */
     static void checkSimplePlaceholders(
-            List<String> errors, String fullUri, int uriLineIdx, Map<String, 
Integer> optionLineMap, String eipName) {
+            List<String> errors, String fullUri, int uriLineIdx, Map<String, 
Integer> optionLineMap, String eipName,
+            boolean uriForm) {
         if (eipName != null && DYNAMIC_URI_EIPS.contains(eipName)) {
             return;
         }
@@ -499,7 +520,8 @@ final class EndpointChecks {
             String key = YamlLines.propertyKeyOf(value);
             errors.add(linePrefix(optionLineMap.getOrDefault(name, 
uriLineIdx)) + scheme + ": " + name + "=" + value
                        + " is a Simple expression, which an endpoint option is 
not evaluated as: a property placeholder"
-                       + " is written {{key}}, so " + name + ": \"{{" + key + 
"}}\"");
+                       + " is written {{key}}, so "
+                       + (uriForm ? name + "={{" + key + "}}" : name + ": 
\"{{" + key + "}}\""));
         }
     }
 
@@ -509,7 +531,8 @@ final class EndpointChecks {
      * (CAMEL-24858). Says both places it can go.
      */
     static void checkRequiredPathOptions(
-            List<String> errors, String fullUri, CamelCatalog catalog, int 
uriLineIdx, String eipName) {
+            List<String> errors, String fullUri, CamelCatalog catalog, int 
uriLineIdx, String eipName,
+            boolean uriForm) {
         int colon = fullUri.indexOf(':');
         if (colon < 0 || fullUri.contains("{{") || !"from".equals(eipName) && 
!"to".equals(eipName)) {
             return; // only an endpoint that is created: an intercept pattern 
such as jms* names no destination
@@ -538,8 +561,10 @@ final class EndpointChecks {
                 if (option.isRequired() && "path".equals(option.getKind()) && 
!given.contains(option.getName())) {
                     errors.add(linePrefix(uriLineIdx) + scheme + ": the 
required option '" + option.getName()
                                + "' is missing (the runtime says 'Option " + 
option.getName() + " is required'): write"
-                               + " it in the uri, uri: " + scheme + ":<" + 
option.getName() + ">, or under parameters"
-                               + " as " + option.getName() + ": <value>");
+                               + (uriForm
+                                       ? " it as the path of the uri, " + 
scheme + ":<" + option.getName() + ">"
+                                       : " it in the uri, uri: " + scheme + 
":<" + option.getName()
+                                         + ">, or under parameters as " + 
option.getName() + ": <value>"));
                 }
             }
         } catch (Exception e) {
@@ -564,11 +589,48 @@ final class EndpointChecks {
         return sb.toString();
     }
 
+    /**
+     * Whether an option the catalog does not know is an entry of a map 
option, written name[key]:
+     * additional-properties[transactional.id] on kafka is the key 
transactional.id of additionalProperties, which the
+     * runtime binds as such.
+     */
+    static boolean isMapKey(CamelCatalog catalog, String scheme, String name) {
+        int bracket = name.indexOf('[');
+        if (bracket <= 0 || !name.endsWith("]")) {
+            return false;
+        }
+        String base = name.substring(0, bracket);
+        String camel = StringHelper.dashToCamelCase(base);
+        try {
+            var model = catalog.componentModel(scheme);
+            if (model == null) {
+                return false;
+            }
+            for (var option : model.getEndpointOptions()) {
+                String prefix = option.getPrefix();
+                if (option.getName().equals(camel) && option.getJavaType() != 
null
+                        && option.getJavaType().startsWith("java.util.Map")
+                        || option.isMultiValue() && prefix != null
+                                && (prefix.equals(base + ".") || 
prefix.equals(camel + "."))) {
+                    return true;
+                }
+            }
+        } catch (Exception e) {
+            // a component the catalog cannot read: say nothing rather than 
the wrong thing
+            return true;
+        }
+        return false;
+    }
+
     static void collectEndpointErrors(
-            List<String> errors, EndpointValidationResult result, String 
scheme,
+            List<String> errors, EndpointValidationResult result, String 
scheme, CamelCatalog catalog,
             int uriLineIdx, Map<String, Integer> optionLineMap) {
-        if (result.getUnknown() != null) {
+        if (result.getUnknown() != null && !"kamelet".equals(scheme)) {
+            // the options of a kamelet: endpoint are the parameters of its 
template, which the catalog does not know
             for (String name : result.getUnknown()) {
+                if (isMapKey(catalog, scheme, name)) {
+                    continue;
+                }
                 StringBuilder sb = new StringBuilder(scheme).append(": Unknown 
option '").append(name).append("'");
                 if (result.getUnknownSuggestions() != null) {
                     String[] suggestions = 
result.getUnknownSuggestions().get(name);
@@ -609,6 +671,11 @@ final class EndpointChecks {
         }
         if (result.getNotProducerOnly() != null) {
             for (String name : result.getNotProducerOnly()) {
+                if ("exchangePattern".equals(name)) {
+                    // a consumer option to the catalog, but a to reads it 
from the uri too (SendProcessor resolves the
+                    // pattern from the url): to 
jms:queue:x?exchangePattern=InOut is a request-reply
+                    continue;
+                }
                 errors.add(linePrefix(optionLineMap.getOrDefault(name, 
uriLineIdx))
                            + scheme + ": Option '" + name + "' is not 
applicable in producer only mode (to: sends to the"
                            + " endpoint, for " + scheme + ": it writes the 
body; to read from an endpoint in the middle of a"
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ModelChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ModelChecks.java
new file mode 100644
index 000000000000..dbfdeba90d26
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ModelChecks.java
@@ -0,0 +1,238 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteNodes.Kind;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteNodes.Node;
+import org.apache.camel.dsl.yaml.validator.EndpointConsumers;
+import org.apache.camel.tooling.model.BaseOptionModel;
+import org.apache.camel.tooling.model.ComponentModel;
+
+/**
+ * The Camel checks of a route source read into the model ({@link 
RouteModel}), whatever its DSL: the endpoint uris
+ * against the catalog, the simple expressions, a to that needs toD, the 
direct and seda endpoints no route consumes.
+ * They are the checks the YAML DSL has, run on the model instead of the YAML 
text (CAMEL-25208), and they report only
+ * what they are sure of: a value the parser could not know is left alone.
+ */
+final class ModelChecks {
+
+    /** The EIPs that consume from their endpoint; the others send to it. */
+    private static final Set<String> CONSUMERS = Set.of("from", "poll", 
"pollEnrich");
+    /** The EIPs whose uri is evaluated per message, where ${...} is what it 
is for. */
+    private static final Set<String> DYNAMIC = Set.of("toD", "wireTap", 
"enrich", "pollEnrich");
+    /** The EIPs that send to a direct: or seda: endpoint some route must 
consume. */
+    private static final Set<String> SENDING = Set.of("to", "wireTap", 
"enrich");
+    /** The one component whose path is a script, so what is in it is not the 
catalog's to say. */
+    private static final Set<String> SCRIPT_PATH = Set.of("language");
+    private static final Pattern OPTION_ON_LINE = 
Pattern.compile("[?&\"'\\s(]%s=");
+
+    private final RouteModel model;
+    private final List<Node> nodes;
+    private final String[] lines;
+    private final CamelCatalog catalog;
+    private final List<String> errors = new ArrayList<>();
+
+    private ModelChecks(RouteModel model, List<Node> nodes, String content, 
CamelCatalog catalog) {
+        this.model = model;
+        this.nodes = nodes;
+        this.lines = content.split("\n", -1);
+        this.catalog = catalog;
+    }
+
+    /**
+     * The problems found, each "Line N: message".
+     *
+     * @param consumedElsewhere the direct: and seda: endpoints the other 
route files of the application consume; null
+     *                          when they are not known, which leaves out the 
check of the endpoints no route consumes
+     */
+    static List<String> check(
+            RouteModel model, List<Node> nodes, String content, CamelCatalog 
catalog, Set<String> consumedElsewhere) {
+        ModelChecks checks = new ModelChecks(model, nodes, content, catalog);
+        for (Node n : nodes) {
+            if (n.kind() == Kind.ENDPOINT) {
+                checks.endpoint(n);
+            } else if (n.kind() == Kind.EXPRESSION) {
+                checks.expression(n);
+            }
+        }
+        checks.consumers(consumedElsewhere);
+        return checks.errors;
+    }
+
+    private void endpoint(Node n) {
+        String uri = n.uri() != null ? n.uri().strip() : null;
+        if (uri == null || uri.isEmpty() || RouteModel.hasUnknownValue(uri) || 
uri.startsWith("{{")) {
+            return;
+        }
+        int lineIdx = Math.max(0, n.line() - 1);
+        String prefix = EndpointChecks.linePrefix(lineIdx);
+        if (EndpointChecks.SEVERAL_ENDPOINTS_PATTERN.matcher(uri).find()) {
+            errors.add(prefix + "a " + n.eip() + " takes one endpoint; \"" + 
uri + "\" names several: send to each with a"
+                       + " multicast (all of them), a recipientList (a list 
evaluated at runtime), or one "
+                       + n.eip() + " per endpoint");
+            return;
+        }
+        if (!uri.contains(":")) {
+            uri = uri + ":";
+        }
+        String scheme = uri.substring(0, uri.indexOf(':'));
+        boolean consumer = CONSUMERS.contains(n.eip());
+        if ("from".equals(n.eip())) {
+            ComponentModel cm = componentModel(scheme);
+            if (cm != null && cm.isProducerOnly()) {
+                errors.add(prefix + scheme + " is a producer-only component: a 
route cannot consume from it (the runtime"
+                           + " says 'You cannot consume from this endpoint'); 
to pass messages between routes send to"
+                           + " direct:name and consume from direct:name (or 
seda: for a queue)");
+                return;
+            }
+        }
+        if ("to".equals(n.eip())) {
+            String expression = expressionInPath(uri, scheme);
+            if (expression != null) {
+                errors.add(prefix + "the uri of the to holds an expression (" 
+ expression + ") but the endpoint of a to"
+                           + " is fixed when the route starts, so it is sent 
as text: use toD to build the uri for"
+                           + " each message");
+                return;
+            }
+        }
+        EndpointChecks.checkUri(errors, catalog, uri, uri, lineIdx, 
optionLines(uri, lineIdx), n.eip(), consumer,
+                !consumer, true);
+    }
+
+    private void expression(Node n) {
+        String text = n.text();
+        if (!"simple".equals(n.language()) || text == null || text.isBlank() 
|| RouteModel.hasUnknownValue(text)
+                || SimpleChecks.hasPlaceholderAsLogicalOperand(text)) {
+            return;
+        }
+        boolean logMessage = "log".equals(n.eip()) && 
"message".equals(n.option());
+        SimpleChecks.checkText(errors, catalog, text, Math.max(1, n.line()), 
n.predicate() && !logMessage, logMessage,
+                n.isInside("aggregate"));
+    }
+
+    /**
+     * The direct: and seda: endpoints the routes send to and no route 
consumes: not the routes of this source, nor
+     * those of the other route files. A direct: without a consumer fails the 
exchange, a seda: one fills a queue no one
+     * reads.
+     */
+    private void consumers(Set<String> consumedElsewhere) {
+        if (consumedElsewhere == null || model.hasTemplates()) {
+            return;
+        }
+        Set<String> consumed = new HashSet<>(consumedElsewhere);
+        for (Node n : nodes) {
+            if (n.kind() == Kind.ENDPOINT && "from".equals(n.eip())) {
+                if (n.uri() == null || EndpointConsumers.isDynamic(n.uri()) || 
RouteModel.hasUnknownValue(n.uri())) {
+                    // a route that consumes an endpoint only known at runtime 
could consume any of them
+                    return;
+                }
+                String endpoint = EndpointConsumers.endpoint(n.uri());
+                if (endpoint != null) {
+                    consumed.add(endpoint);
+                }
+            }
+        }
+        Set<String> reported = new HashSet<>();
+        for (Node n : nodes) {
+            if (n.kind() != Kind.ENDPOINT || !SENDING.contains(n.eip()) || 
RouteModel.hasUnknownValue(n.uri())) {
+                continue;
+            }
+            String endpoint = EndpointConsumers.endpoint(n.uri());
+            if (endpoint == null || consumed.contains(endpoint) || 
!reported.add(endpoint)) {
+                continue;
+            }
+            String from = model.dsl() == RouteModel.Dsl.XML
+                    ? "<from uri=\"" + endpoint + "\"/>" : "from(\"" + 
endpoint + "\")";
+            errors.add(EndpointChecks.linePrefix(Math.max(0, n.line() - 1)) + 
"sends to " + endpoint
+                       + ", and no route consumes it - not in this file, nor 
in the other route files of the directory: "
+                       + (endpoint.startsWith("direct:")
+                               ? "the exchange fails with No consumers 
available on endpoint"
+                               : "nothing fails, the messages are queued and 
never read")
+                       + "; add a route with " + from + ", or correct the 
name");
+        }
+    }
+
+    /**
+     * The line of each option of the uri written on a line of its own, as a 
Java uri built over several lines: the
+     * first line at or after the uri's that has the option.
+     */
+    private Map<String, Integer> optionLines(String uri, int lineIdx) {
+        Map<String, Integer> answer = new LinkedHashMap<>();
+        int q = uri.indexOf('?');
+        if (q < 0) {
+            return answer;
+        }
+        for (String pair : uri.substring(q + 1).split("&")) {
+            int eq = pair.indexOf('=');
+            String name = eq > 0 ? pair.substring(0, eq) : pair;
+            if (name.isEmpty() || !name.matches("[\\w.\\[\\]-]+")) {
+                continue;
+            }
+            Pattern p = 
Pattern.compile(String.format(OPTION_ON_LINE.pattern(), Pattern.quote(name)));
+            for (int i = lineIdx; i < lines.length && i < lineIdx + 10; i++) {
+                if (p.matcher(lines[i]).find()) {
+                    if (i != lineIdx) {
+                        answer.put(name, i);
+                    }
+                    break;
+                }
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * The first simple expression in the path of the uri (what comes before 
the options), or null when there is none or
+     * the component evaluates its path for each message, as its catalog 
metadata says (micrometer:counter:${...}).
+     */
+    private String expressionInPath(String uri, String scheme) {
+        if (SCRIPT_PATH.contains(scheme) || 
EndpointChecks.FILE_SCHEMES.contains(scheme)) {
+            // the directory of a file endpoint has its own check, which says 
to move the dynamic part to fileName
+            return null;
+        }
+        String head = uri.indexOf('?') > 0 ? uri.substring(0, 
uri.indexOf('?')) : uri;
+        int start = head.indexOf("${");
+        if (start < 0 || start >= 2 && head.startsWith(":#", start - 2)) {
+            // :#${...} is a parameter the component binds per message, not a 
part of the address
+            return null;
+        }
+        ComponentModel cm = componentModel(scheme);
+        if (cm == null || 
cm.getEndpointPathOptions().stream().anyMatch(BaseOptionModel::isSupportSimpleExpression))
 {
+            // a component the catalog does not know: say nothing rather than 
the wrong thing
+            return null;
+        }
+        int end = head.indexOf('}', start);
+        return end > 0 ? head.substring(start, end + 1) : 
head.substring(start);
+    }
+
+    private ComponentModel componentModel(String scheme) {
+        try {
+            return catalog.componentModel(scheme);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteAssist.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteAssist.java
new file mode 100644
index 000000000000..ef94930d4a36
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteAssist.java
@@ -0,0 +1,181 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.catalog.CamelCatalog;
+
+/**
+ * Camel assistance for the routes of a Java or XML DSL source, for any tool: 
the TUI Source tab, the MCP tools, the
+ * camel validate CLI, or an editor. The operations are those of a language 
server - the problems of a source, what is
+ * on a line - without the protocol: the source is read into the Camel model 
({@link RouteModel}) and its nodes by line
+ * ({@link RouteNodes}), so the answers are the same whatever the DSL 
(CAMEL-25208).
+ */
+public final class RouteAssist {
+
+    /** How sure a diagnostic is. */
+    public enum Severity {
+        /** The source is wrong: the route fails to start, or does not do what 
it says. */
+        ERROR,
+        /** Something to know, such as a part of the source that was not 
checked. */
+        INFO
+    }
+
+    /**
+     * A problem of a source.
+     *
+     * @param line     the line, 1-based; 0 when it is about the whole source
+     * @param severity how sure it is
+     * @param message  what is wrong, and what to write instead
+     */
+    public record Diagnostic(int line, Severity severity, String message) {
+
+        /** The diagnostic as the validators write it: "Line N: message". */
+        public String format() {
+            return line > 0 ? "Line " + line + ": " + message : message;
+        }
+    }
+
+    /**
+     * The parts the parser does not read that are the application's own code 
(a lambda, an object it creates, one of
+     * its classes): nothing of Camel's is left unchecked there, so they are 
not reported.
+     */
+    private static final Set<String> CODE
+            = Set.of("a lambda or method reference", "an object created in the 
route", "an anonymous class",
+                    "a class of the project");
+
+    private static final Pattern LINE_PREFIX = Pattern.compile("(?s)^Line 
(\\d+): (.*)");
+
+    private RouteAssist() {
+    }
+
+    /** Whether the file is a Java or XML DSL source the assistance reads. */
+    public static boolean supports(String fileName, String content) {
+        return RouteModel.dslOf(fileName, content) != null;
+    }
+
+    /**
+     * The problems of a Java or XML DSL source: errors (an endpoint option 
the component does not have, a simple
+     * expression that does not parse, a to that needs toD, a direct: endpoint 
no route consumes) and what was not
+     * checked (a part the parser could not read). Empty for a file that is 
not a Java or XML DSL source.
+     *
+     * @param directory the directory of the application's files, for the 
constants of the other Java classes and the
+     *                  endpoints the other route files consume; null to check 
the source on its own
+     */
+    public static List<Diagnostic> diagnostics(String fileName, String 
content, CamelCatalog catalog, Path directory) {
+        return diagnostics(fileName, content, catalog, directory, 
javaSources(directory), directory != null);
+    }
+
+    /**
+     * As {@link #diagnostics(String, String, CamelCatalog, Path)} with the 
Java sources of the project given, and
+     * whether to check the direct: and seda: endpoints no route consumes (a 
tool that writes one file after another
+     * leaves it out: the consumer is often a file not written yet).
+     */
+    public static List<Diagnostic> diagnostics(
+            String fileName, String content, CamelCatalog catalog, Path 
directory,
+            Map<String, Supplier<String>> javaSources, boolean checkConsumers) 
{
+        RouteModel model = RouteModel.read(fileName, content, catalog, 
javaSources);
+        if (model == null) {
+            return List.of();
+        }
+        List<Diagnostic> answer = new ArrayList<>();
+        for (RouteModel.Unread u : model.unread()) {
+            if (u.error()) {
+                answer.add(new Diagnostic(u.line(), Severity.ERROR, 
u.reason()));
+            }
+        }
+        List<RouteNodes.Node> nodes = RouteNodes.of(model, catalog);
+        Set<String> consumed = checkConsumers && directory != null
+                ? EndpointConsumerChecks.consumed(directory, fileName) : null;
+        for (String msg : ModelChecks.check(model, nodes, content, catalog, 
consumed)) {
+            answer.add(parse(msg, Severity.ERROR));
+        }
+        for (RouteModel.Unread u : model.unread()) {
+            if (!u.error() && !CODE.contains(u.reason())) {
+                answer.add(new Diagnostic(u.line(), Severity.INFO, "not 
checked: " + describe(u)));
+            }
+        }
+        answer.sort((a, b) -> Integer.compare(a.line(), b.line()));
+        return answer;
+    }
+
+    /**
+     * The nodes of a Java or XML DSL source by line - steps, endpoints, 
expressions - for a quick doc or a hover; empty
+     * for a file that is not one.
+     */
+    public static List<RouteNodes.Node> nodes(
+            String fileName, String content, CamelCatalog catalog, Map<String, 
Supplier<String>> javaSources) {
+        RouteModel model = RouteModel.read(fileName, content, catalog, 
javaSources);
+        return model != null ? RouteNodes.of(model, catalog) : List.of();
+    }
+
+    /**
+     * The Java sources of the project under the directory, read when first 
needed, for the constants a route refers to
+     * in another class.
+     */
+    public static Map<String, Supplier<String>> javaSources(Path directory) {
+        Map<String, Supplier<String>> answer = new LinkedHashMap<>();
+        if (directory == null || !Files.isDirectory(directory)) {
+            return answer;
+        }
+        for (Path p : AuthoringTools.projectFiles(directory)) {
+            String rel = AuthoringTools.relativePath(directory, p);
+            if (rel.endsWith(".java")) {
+                answer.put(rel, () -> {
+                    try {
+                        return Files.readString(p, StandardCharsets.UTF_8);
+                    } catch (IOException e) {
+                        return null;
+                    }
+                });
+            }
+        }
+        return answer;
+    }
+
+    private static String describe(RouteModel.Unread u) {
+        String text = u.text();
+        if (text == null || text.isBlank()) {
+            return u.reason();
+        }
+        text = text.strip().replaceAll("\\s+", " ");
+        if (text.length() > 60) {
+            text = text.substring(0, 57) + "...";
+        }
+        return text + " (" + u.reason() + ")";
+    }
+
+    private static Diagnostic parse(String msg, Severity severity) {
+        Matcher m = LINE_PREFIX.matcher(msg);
+        if (m.matches()) {
+            return new Diagnostic(Integer.parseInt(m.group(1)), severity, 
m.group(2));
+        }
+        return new Diagnostic(0, severity, msg);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteModel.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteModel.java
new file mode 100644
index 000000000000..bce759548ada
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteModel.java
@@ -0,0 +1,278 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.java.in.JavaParseResult;
+import org.apache.camel.java.in.LwJavaParser;
+import org.apache.camel.model.RouteConfigurationDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.RouteTemplateDefinition;
+import org.apache.camel.model.app.BeansDefinition;
+import org.apache.camel.model.rest.RestDefinition;
+import org.apache.camel.xml.in.ModelParser;
+import org.apache.camel.xml.io.XmlPullParserException;
+import org.apache.camel.xml.io.XmlPullParserLocationException;
+
+/**
+ * The routes of a Java or XML DSL source read into the Camel model, with the 
line of each step, without compiling or
+ * running anything: Java by the Java DSL parser of camel-java-io (the 
endpoint DSL and constants resolved through the
+ * catalog), XML by the parser of camel-xml-io the XML DSL loads routes with. 
What could not be read is kept with its
+ * line, so a tool can say which parts it did not check.
+ * <p/>
+ * The model is the same whatever the DSL, which is what lets one set of 
checks and one quick doc serve Java and XML
+ * (CAMEL-25208).
+ */
+public final class RouteModel {
+
+    /** The DSL of a source. */
+    public enum Dsl {
+        JAVA,
+        XML
+    }
+
+    /**
+     * A part of the source that was not read into the model.
+     *
+     * @param line   the line, 1-based; 0 when not known
+     * @param text   the source text of the part, or null
+     * @param reason why it was not read
+     * @param error  whether the source is wrong there (an XML element the XML 
DSL does not have), rather than beyond
+     *               what a parse can read (a lambda, a value computed at 
runtime)
+     */
+    public record Unread(int line, String text, String reason, boolean error) {
+    }
+
+    /** The root elements of a Camel XML DSL file, as the XML DSL loads them. 
*/
+    private static final List<String> XML_ROOTS = List.of(
+            "camel", "beans", "routes", "route", "routeConfigurations", 
"routeConfiguration", "routeTemplates",
+            "routeTemplate", "rests", "rest");
+
+    /** The first element of an XML document, after the prolog, comments and 
doctype. */
+    private static final Pattern XML_ROOT = 
Pattern.compile("<(?:[A-Za-z_][\\w.-]*:)?([A-Za-z_][\\w.-]*)([^>]*)>");
+    private static final Pattern XML_NS = 
Pattern.compile("\\bxmlns\\s*=\\s*[\"']([^\"']*)[\"']");
+    private static final Pattern CAMEL_CONTENT
+            = 
Pattern.compile("<(route|rest|routeConfiguration|routeTemplate|templatedRoute)[\\s>]");
+    private static final Pattern XML_SKIPPED = 
Pattern.compile("(?s)<\\?.*?\\?>|<!--.*?-->|<!DOCTYPE[^>]*>");
+
+    private final Dsl dsl;
+    private final List<RouteDefinition> routes;
+    private final List<RouteConfigurationDefinition> routeConfigurations;
+    private final List<RestDefinition> rests;
+    private final boolean templates;
+    private final List<Unread> unread;
+
+    private RouteModel(Dsl dsl, List<RouteDefinition> routes, 
List<RouteConfigurationDefinition> routeConfigurations,
+                       List<RestDefinition> rests, boolean templates, 
List<Unread> unread) {
+        this.dsl = dsl;
+        this.routes = routes;
+        this.routeConfigurations = routeConfigurations;
+        this.rests = rests;
+        this.templates = templates;
+        this.unread = unread;
+    }
+
+    public Dsl dsl() {
+        return dsl;
+    }
+
+    /** The routes, and the routes of the route templates. */
+    public List<RouteDefinition> routes() {
+        return routes;
+    }
+
+    public List<RouteConfigurationDefinition> routeConfigurations() {
+        return routeConfigurations;
+    }
+
+    public List<RestDefinition> rests() {
+        return rests;
+    }
+
+    /**
+     * Whether the source has route templates, or routes made from them: what 
the routes made from a template consume is
+     * only known when it is used.
+     */
+    public boolean hasTemplates() {
+        return templates;
+    }
+
+    /** What was not read, in source order. */
+    public List<Unread> unread() {
+        return unread;
+    }
+
+    /** The DSL of a file by its name and content, or null when it is not a 
Java or XML DSL source. */
+    public static Dsl dslOf(String fileName, String content) {
+        if (fileName == null || content == null) {
+            return null;
+        }
+        String name = fileName.toLowerCase(Locale.ROOT);
+        if (name.endsWith(".java")) {
+            // a RouteBuilder, EndpointRouteBuilder, 
RouteConfigurationBuilder...
+            return content.contains("RouteBuilder") || 
content.contains("RouteConfigurationBuilder") ? Dsl.JAVA : null;
+        }
+        if (name.endsWith(".xml")) {
+            String[] root = xmlRoot(content);
+            if (root == null || !XML_ROOTS.contains(root[0]) || 
!isCamelNamespace(root[1])) {
+                return null;
+            }
+            // a beans root without a namespace is Camel's when it holds 
routes (a beans.xml of CDI does not)
+            return !"beans".equals(root[0]) || !root[1].isEmpty() || 
CAMEL_CONTENT.matcher(content).find() ? Dsl.XML : null;
+        }
+        return null;
+    }
+
+    /**
+     * Reads a Java or XML DSL source.
+     *
+     * @param  javaSources the Java sources of the project by path, for the 
constants of other classes; may be empty
+     * @return             the model, or null when the file is not a Java or 
XML DSL source
+     */
+    public static RouteModel read(
+            String fileName, String content, CamelCatalog catalog, Map<String, 
Supplier<String>> javaSources) {
+        Dsl dsl = dslOf(fileName, content);
+        if (dsl == Dsl.JAVA) {
+            return readJava(content, catalog, javaSources);
+        } else if (dsl == Dsl.XML) {
+            return readXml(content);
+        }
+        return null;
+    }
+
+    private static RouteModel readJava(String content, CamelCatalog catalog, 
Map<String, Supplier<String>> javaSources) {
+        JavaParseResult result = ProjectRoutes.parseJava(content, javaSources 
!= null ? javaSources : Map.of(), catalog);
+        List<RouteDefinition> routes = new 
ArrayList<>(result.routes().getRoutes());
+        for (RouteTemplateDefinition t : 
result.routeTemplates().getRouteTemplates()) {
+            if (t.getRoute() != null) {
+                routes.add(t.getRoute());
+            }
+        }
+        List<Unread> unread = new ArrayList<>();
+        for (JavaParseResult.Unresolved u : result.unresolved()) {
+            if (!JavaParseResult.configuresTheContext(u)) {
+                // a statement that sets up the CamelContext (components, 
beans) is not a part of a route
+                unread.add(new Unread(u.line(), u.text(), u.reason(), false));
+            }
+        }
+        return new RouteModel(
+                Dsl.JAVA, routes, 
result.routeConfigurations().getRouteConfigurations(),
+                result.rests().getRests(), 
!result.routeTemplates().getRouteTemplates().isEmpty(), unread);
+    }
+
+    private static RouteModel readXml(String content) {
+        String[] root = xmlRoot(content);
+        String namespace = root != null ? root[1] : "";
+        List<RouteDefinition> routes = new ArrayList<>();
+        List<RouteConfigurationDefinition> configurations = new ArrayList<>();
+        List<RestDefinition> rests = new ArrayList<>();
+        List<Unread> unread = new ArrayList<>();
+        boolean templates = false;
+        try {
+            ModelParser parser = new ModelParser(new StringReader(content), 
namespace);
+            switch (root != null ? root[0] : "") {
+                case "camel", "beans" -> {
+                    BeansDefinition beans = 
parser.parseBeansDefinition().orElse(null);
+                    if (beans != null) {
+                        routes.addAll(beans.getRoutes());
+                        for (RouteTemplateDefinition t : 
beans.getRouteTemplates()) {
+                            if (t.getRoute() != null) {
+                                routes.add(t.getRoute());
+                            }
+                        }
+                        configurations.addAll(beans.getRouteConfigurations());
+                        rests.addAll(beans.getRests());
+                        templates = !beans.getRouteTemplates().isEmpty() || 
!beans.getTemplatedRoutes().isEmpty();
+                    }
+                }
+                case "routeConfigurations", "routeConfiguration" -> 
parser.parseRouteConfigurationsDefinition()
+                        .ifPresent(c -> 
configurations.addAll(c.getRouteConfigurations()));
+                case "routeTemplates", "routeTemplate" -> {
+                    var t = 
parser.parseRouteTemplatesDefinition().orElse(null);
+                    if (t != null) {
+                        for (RouteTemplateDefinition template : 
t.getRouteTemplates()) {
+                            if (template.getRoute() != null) {
+                                routes.add(template.getRoute());
+                            }
+                        }
+                        templates = true;
+                    }
+                }
+                case "rests", "rest" -> 
parser.parseRestsDefinition().ifPresent(r -> rests.addAll(r.getRests()));
+                default -> parser.parseRoutesDefinition().ifPresent(r -> 
routes.addAll(r.getRoutes()));
+            }
+        } catch (XmlPullParserLocationException e) {
+            // an element or attribute the XML DSL does not have: the XML DSL 
refuses the file the same way
+            unread.add(new Unread(e.getLineNumber(), null, 
firstLine(e.getCause() != null ? e.getCause() : e), true));
+        } catch (XmlPullParserException e) {
+            unread.add(new Unread(Math.max(0, e.getLineNumber()), null, 
firstLine(e), true));
+        } catch (Exception e) {
+            unread.add(new Unread(0, null, firstLine(e), true));
+        }
+        return new RouteModel(Dsl.XML, routes, configurations, rests, 
templates, unread);
+    }
+
+    private static String firstLine(Throwable e) {
+        String msg = String.valueOf(e.getMessage());
+        int nl = msg.indexOf('\n');
+        return nl > 0 ? msg.substring(0, nl).trim() : msg.trim();
+    }
+
+    /** The local name and the default namespace of the root element; null 
when there is none. */
+    static String[] xmlRoot(String content) {
+        String body = XML_SKIPPED.matcher(content).replaceAll("");
+        Matcher m = XML_ROOT.matcher(body);
+        if (!m.find()) {
+            return null;
+        }
+        Matcher ns = XML_NS.matcher(m.group(2));
+        return new String[] { m.group(1), ns.find() ? ns.group(1) : "" };
+    }
+
+    /**
+     * Whether the root element is of the XML DSL: no namespace, or a Camel 
one. A Spring or Blueprint file keeps its
+     * routes in a camelContext the XML DSL does not read, and a beans.xml of 
CDI is not about Camel.
+     */
+    private static boolean isCamelNamespace(String namespace) {
+        return namespace.isEmpty() || namespace.contains("camel.apache.org");
+    }
+
+    /** Whether a value in the model is one the Java DSL parser did not know, 
written ?{source text}. */
+    static boolean hasUnknownValue(String value) {
+        if (value == null) {
+            return false;
+        }
+        int i = value.indexOf(LwJavaParser.UNRESOLVED_PREFIX);
+        while (i >= 0) {
+            // ?{{key}} is a property placeholder after the ? of the options, 
not an unknown value
+            if (i + 2 >= value.length() || value.charAt(i + 2) != '{') {
+                return true;
+            }
+            i = value.indexOf(LwJavaParser.UNRESOLVED_PREFIX, i + 3);
+        }
+        return false;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteNodes.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteNodes.java
new file mode 100644
index 000000000000..baaaae1dc7dc
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteNodes.java
@@ -0,0 +1,305 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.LineNumberAware;
+import org.apache.camel.NamedNode;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.model.EnrichDefinition;
+import org.apache.camel.model.ExpressionNode;
+import org.apache.camel.model.ExpressionSubElementDefinition;
+import org.apache.camel.model.FromDefinition;
+import org.apache.camel.model.InterceptFromDefinition;
+import org.apache.camel.model.InterceptSendToEndpointDefinition;
+import org.apache.camel.model.LogDefinition;
+import org.apache.camel.model.LoopDefinition;
+import org.apache.camel.model.PollDefinition;
+import org.apache.camel.model.PollEnrichDefinition;
+import org.apache.camel.model.ProcessorDefinition;
+import org.apache.camel.model.RouteConfigurationDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.SendDefinition;
+import org.apache.camel.model.SwitchCaseDefinition;
+import org.apache.camel.model.SwitchDefinition;
+import org.apache.camel.model.SwitchOtherwiseDefinition;
+import org.apache.camel.model.ToDynamicDefinition;
+import org.apache.camel.model.language.ConstantExpression;
+import org.apache.camel.model.language.ExpressionDefinition;
+import org.apache.camel.model.rest.RestDefinition;
+import org.apache.camel.tooling.model.EipModel;
+
+/**
+ * The nodes of a {@link RouteModel} by line: each step, each endpoint it 
consumes from or sends to, and each expression
+ * with its language. What a tool asks of a line - a quick doc, a check - it 
asks of these nodes, whatever the DSL the
+ * source is written in.
+ * <p/>
+ * A node without a line of its own (an expression, the endpoint of a rest 
verb) takes the line of the step it belongs
+ * to.
+ */
+public final class RouteNodes {
+
+    private static final int MAX_DEPTH = 64;
+
+    /** What a node is. */
+    public enum Kind {
+        /** A step: an EIP such as filter, split, to. */
+        STEP,
+        /** An endpoint a step consumes from or sends to. */
+        ENDPOINT,
+        /** An expression or predicate of a step. */
+        EXPRESSION
+    }
+
+    /**
+     * A node of a route.
+     *
+     * @param kind      what the node is
+     * @param line      the line, 1-based; 0 when not known
+     * @param eip       the EIP: of the step, of the step an endpoint belongs 
to (from, to, toD, wireTap...), or of the
+     *                  step an expression belongs to
+     * @param uri       the endpoint uri (ENDPOINT)
+     * @param language  the language (EXPRESSION)
+     * @param text      the text of the expression (EXPRESSION)
+     * @param option    the option of the step the expression is (expression, 
completionPredicate, message...)
+     * @param predicate whether the step evaluates the expression as a 
predicate
+     * @param parents   the EIPs the node is nested in, outermost first
+     */
+    public record Node(
+            Kind kind, int line, String eip, String uri, String language, 
String text, String option, boolean predicate,
+            List<String> parents) {
+
+        /** Whether the node is nested in the given EIP, at any depth. */
+        public boolean isInside(String parentEip) {
+            return parents.contains(parentEip);
+        }
+    }
+
+    private final CamelCatalog catalog;
+    private final List<Node> nodes = new ArrayList<>();
+    private final Set<Object> seen = Collections.newSetFromMap(new 
IdentityHashMap<>());
+
+    private RouteNodes(CamelCatalog catalog) {
+        this.catalog = catalog;
+    }
+
+    /**
+     * The nodes of the model, by line.
+     *
+     * @param catalog to know which expressions are predicates; may be null
+     */
+    public static List<Node> of(RouteModel model, CamelCatalog catalog) {
+        RouteNodes walker = new RouteNodes(catalog);
+        for (RouteDefinition route : model.routes()) {
+            walker.route(route);
+        }
+        for (RouteConfigurationDefinition rc : model.routeConfigurations()) {
+            walker.walk(rc, lineOf(rc, 0), List.of(), 0);
+        }
+        for (RestDefinition rest : model.rests()) {
+            walker.walk(rest, lineOf(rest, 0), List.of("rest"), 0);
+        }
+        List<Node> answer = new ArrayList<>(walker.nodes);
+        answer.sort(Comparator.comparingInt(Node::line));
+        return answer;
+    }
+
+    /** The nodes on a line. */
+    public static List<Node> at(List<Node> nodes, int line) {
+        List<Node> answer = new ArrayList<>();
+        for (Node n : nodes) {
+            if (n.line() == line) {
+                answer.add(n);
+            }
+        }
+        return answer;
+    }
+
+    private void route(RouteDefinition route) {
+        if (!seen.add(route)) {
+            return;
+        }
+        int line = lineOf(route, 0);
+        FromDefinition from = route.getInput();
+        if (from != null) {
+            int fromLine = lineOf(from, line);
+            nodes.add(new Node(Kind.STEP, fromLine, "from", null, null, null, 
null, false, List.of()));
+            if (from.getUri() != null) {
+                nodes.add(new Node(Kind.ENDPOINT, fromLine, "from", 
from.getUri(), null, null, null, false, List.of()));
+            }
+            line = line > 0 ? line : fromLine;
+        }
+        for (ProcessorDefinition<?> p : route.getOutputs()) {
+            walk(p, line, List.of(), 0);
+        }
+    }
+
+    /** Walks a model object: a step, or an object of the model that holds 
steps and expressions. */
+    private void walk(Object o, int ownerLine, List<String> parents, int 
depth) {
+        if (o == null || depth > MAX_DEPTH) {
+            return;
+        }
+        if (o instanceof Collection<?> c) {
+            for (Object e : c) {
+                walk(e, ownerLine, parents, depth + 1);
+            }
+            return;
+        }
+        if (o instanceof Map<?, ?> m) {
+            for (Object e : m.values()) {
+                walk(e, ownerLine, parents, depth + 1);
+            }
+            return;
+        }
+        if (!o.getClass().getName().startsWith("org.apache.camel.model.") || o 
instanceof RouteDefinition
+                || !seen.add(o)) {
+            return;
+        }
+        int line = lineOf(o, ownerLine);
+        List<String> inner = parents;
+        if (o instanceof ProcessorDefinition<?> p) {
+            step(p, line, parents);
+            inner = new ArrayList<>(parents);
+            inner.add(p.getShortName());
+        }
+        for (Class<?> c = o.getClass(); c != null && c != Object.class; c = 
c.getSuperclass()) {
+            for (Field f : c.getDeclaredFields()) {
+                if (Modifier.isStatic(f.getModifiers()) || 
f.getType().isPrimitive() || f.getType() == String.class
+                        || "parent".equals(f.getName()) || 
"blocks".equals(f.getName())) {
+                    continue;
+                }
+                Object v;
+                try {
+                    f.setAccessible(true);
+                    v = f.get(o);
+                } catch (ReflectiveOperationException | RuntimeException e) {
+                    continue;
+                }
+                if (v instanceof ExpressionSubElementDefinition sub) {
+                    v = sub.getExpressionType();
+                }
+                if (v instanceof ExpressionDefinition e) {
+                    expression(e, o, f.getName(), line, inner);
+                } else {
+                    walk(v, line, inner, depth + 1);
+                }
+            }
+        }
+    }
+
+    /** A step, and the endpoints it names. */
+    private void step(ProcessorDefinition<?> p, int line, List<String> 
parents) {
+        String eip = p.getShortName();
+        nodes.add(new Node(Kind.STEP, line, eip, null, null, null, null, 
false, parents));
+        if (p instanceof InterceptFromDefinition || p instanceof 
InterceptSendToEndpointDefinition) {
+            // their uri is a pattern of the endpoints they intercept, not an 
endpoint
+            return;
+        }
+        String uri = null;
+        if (p instanceof ToDynamicDefinition d) {
+            // toD and wireTap
+            uri = d.getUri();
+        } else if (p instanceof SendDefinition<?> s) {
+            uri = s.getUri();
+        } else if (p instanceof PollDefinition poll) {
+            uri = poll.getUri();
+        } else if ((p instanceof EnrichDefinition || p instanceof 
PollEnrichDefinition)
+                && ((ExpressionNode) p).getExpression() instanceof 
ConstantExpression c) {
+            uri = c.getExpression();
+        } else if (p instanceof LogDefinition log && log.getMessage() != null) 
{
+            // the message of a log is a simple template
+            nodes.add(new Node(Kind.EXPRESSION, line, eip, null, "simple", 
log.getMessage(), "message", false, parents));
+        }
+        if (uri != null) {
+            nodes.add(new Node(Kind.ENDPOINT, line, eip, uri, null, null, 
null, false, parents));
+        }
+        if (p instanceof SwitchDefinition sw) {
+            // the destinations of a switch are its cases and fallback, which 
are not steps (each keeps a to of its
+            // own for the runtime, the same endpoint: not walked)
+            for (SwitchCaseDefinition c : sw.getCases()) {
+                seen.add(c);
+                if (c.getToDefinition() != null) {
+                    seen.add(c.getToDefinition());
+                }
+                if (c.getUri() != null) {
+                    nodes.add(new Node(
+                            Kind.ENDPOINT, lineOf(c, line), "to", c.getUri(), 
null, null, null, false,
+                            append(parents, eip)));
+                }
+            }
+            SwitchOtherwiseDefinition otherwise = sw.getOtherwise();
+            if (otherwise != null) {
+                seen.add(otherwise);
+                seen.add(otherwise.getToDefinition());
+                if (otherwise.getUri() != null) {
+                    nodes.add(new Node(
+                            Kind.ENDPOINT, lineOf(otherwise, line), "to", 
otherwise.getUri(), null, null, null,
+                            false, append(parents, eip)));
+                }
+            }
+        }
+    }
+
+    private void expression(ExpressionDefinition e, Object owner, String 
option, int line, List<String> parents) {
+        if (!seen.add(e) || e.getLanguage() == null) {
+            return;
+        }
+        String eip = owner instanceof NamedNode n ? n.getShortName() : null;
+        nodes.add(new Node(
+                Kind.EXPRESSION, line, eip, null, e.getLanguage(), 
e.getExpression(), option,
+                isPredicate(owner, eip, option), parents));
+    }
+
+    /**
+     * Whether the step evaluates the option as a predicate: the expression 
the model marks with @AsPredicate, which the
+     * catalog carries as asPredicate on the option. loop is the one EIP a 
flag on the option cannot describe: its
+     * expression is a predicate only when doWhile is true.
+     */
+    private boolean isPredicate(Object owner, String eip, String option) {
+        if (owner instanceof LoopDefinition loop) {
+            return "true".equals(loop.getDoWhile());
+        }
+        if (catalog == null || eip == null) {
+            return false;
+        }
+        EipModel model = catalog.eipModel(eip);
+        return model != null && SimpleChecks.isPredicateOption(model, option);
+    }
+
+    private static List<String> append(List<String> parents, String eip) {
+        List<String> answer = new ArrayList<>(parents);
+        answer.add(eip);
+        return answer;
+    }
+
+    private static int lineOf(Object o, int fallback) {
+        if (o instanceof LineNumberAware la && la.getLineNumber() > 0) {
+            return la.getLineNumber();
+        }
+        return fallback;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
index 4c1d5d067a2b..4cff024faf1f 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
@@ -108,35 +108,54 @@ final class SimpleChecks {
 
             // Determine predicate vs expression context
             boolean predicate = !isLogMessage && isPredicate(catalog, lines, 
i, lineIndent);
+            checkText(errors, catalog, simpleText, lineNum, predicate, 
isLogMessage,
+                    hasAncestorEip(lines, i, lineIndent, "aggregate"));
+        }
+        return errors;
+    }
 
-            boolean syntaxError = false;
-            try {
-                LanguageValidationResult result = predicate
-                        ? catalog.validateLanguagePredicate(null, "simple", 
simpleText)
-                        : catalog.validateLanguageExpression(null, "simple", 
simpleText);
-                if (!result.isSuccess()) {
-                    String error = result.getShortError() != null ? 
result.getShortError() : result.getError();
-                    if (error != null && !isMissingDependency(error)) {
-                        syntaxError = true;
-                        errors.add("Line " + lineNum + ": Simple syntax error: 
" + error
-                                   + aggregatedSizeHint(error, lines, i, 
lineIndent));
-                    }
+    /**
+     * The checks of one simple text, whatever the DSL it is written in: its 
syntax, as a predicate or an expression,
+     * and a ternary operator written where it is literal text.
+     *
+     * @param lineNum     the line of the text, 1-based
+     * @param predicate   whether the EIP evaluates the text as a predicate
+     * @param logMessage  whether the text is the message of a log, where a ? 
followed by a : is most often a sentence
+     * @param inAggregate whether the text is inside an aggregate, for the 
hint on the size of the group
+     */
+    static void checkText(
+            List<String> errors, CamelCatalog catalog, String text, int 
lineNum, boolean predicate, boolean logMessage,
+            boolean inAggregate) {
+        if (text.startsWith("resource:")) {
+            // the expression is loaded from a file 
(resource:classpath:script.txt), which is not the text here
+            return;
+        }
+        boolean syntaxError = false;
+        try {
+            LanguageValidationResult result = predicate
+                    ? catalog.validateLanguagePredicate(null, "simple", text)
+                    : catalog.validateLanguageExpression(null, "simple", text);
+            if (!result.isSuccess()) {
+                String error = result.getShortError() != null ? 
result.getShortError() : result.getError();
+                if (error != null && !isMissingDependency(error) && 
!isOfTheApplication(error)) {
+                    syntaxError = true;
+                    errors.add("Line " + lineNum + ": Simple syntax error: " + 
error
+                               + (inAggregate ? aggregatedSizeHint(error) : 
""));
                 }
-            } catch (Exception e) {
-                // best effort
             }
+        } catch (Exception e) {
+            // best effort
+        }
 
-            // the parser cannot report this one: the expression is valid, it 
just does not mean what it says.
-            // Only a reported syntax error suppresses it, to keep one message 
per expression; a catalog that could
-            // not be asked at all does not, because this check does not 
depend on it.
-            if (!syntaxError && !predicate && !isLogMessage) {
-                String ternary = topLevelTernary(simpleText);
-                if (ternary != null) {
-                    errors.add("Line " + lineNum + ": " + ternary);
-                }
+        // the parser cannot report this one: the expression is valid, it just 
does not mean what it says.
+        // Only a reported syntax error suppresses it, to keep one message per 
expression; a catalog that could
+        // not be asked at all does not, because this check does not depend on 
it.
+        if (!syntaxError && !predicate && !logMessage) {
+            String ternary = topLevelTernary(text);
+            if (ternary != null) {
+                errors.add("Line " + lineNum + ": " + ternary);
             }
         }
-        return errors;
     }
 
     /**
@@ -194,6 +213,14 @@ final class SimpleChecks {
      * A function served by a language or a component that is not on the 
classpath of the check (${jsonpath(...)},
      * ${a2a:text}): the route works when the dependency is there, which the 
check cannot know, so it is not reported.
      */
+    /**
+     * Whether the error is about something only the application has: a class 
of its own (${type:com.acme.Codes.OK},
+     * which the classpath of the check does not have), or a custom function 
it registers (${function(name)}).
+     */
+    static boolean isOfTheApplication(String error) {
+        return error.contains("ClassNotFoundException") || 
error.startsWith("No custom simple function");
+    }
+
     static boolean isMissingDependency(String error) {
         return error.startsWith("No language could be found for:")
                 || error.startsWith("No " + 
SimpleLanguageFunctionFactory.FACTORY + "/")
@@ -233,7 +260,7 @@ final class SimpleChecks {
         return owner != null && isPredicateOption(owner, parent);
     }
 
-    private static boolean isPredicateOption(EipModel eip, String option) {
+    static boolean isPredicateOption(EipModel eip, String option) {
         return eip.getOptions().stream()
                 .anyMatch(o -> option.equals(o.getName()) && 
"expression".equals(o.getKind()) && o.isAsPredicate());
     }
@@ -242,13 +269,10 @@ final class SimpleChecks {
      * ${size} or ${count} written inside an aggregate: the parser's 
did-you-mean (${length}) is about the function,
      * what the author wants is the number of aggregated messages, an exchange 
property.
      */
-    static String aggregatedSizeHint(String error, String[] lines, int 
lineIdx, int lineIndent) {
+    static String aggregatedSizeHint(String error) {
         if (!error.contains("Unknown function: size") && 
!error.contains("Unknown function: count")) {
             return "";
         }
-        if (!hasAncestorEip(lines, lineIdx, lineIndent, "aggregate")) {
-            return "";
-        }
         return " (inside an aggregate the number of aggregated messages is"
                + " ${exchangeProperty.CamelAggregatedSize}, and 
${exchangeProperty.CamelAggregatedCompletedBy}"
                + " says what completed the group)";
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteAssistTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteAssistTest.java
new file mode 100644
index 000000000000..e0a57529fd40
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RouteAssistTest.java
@@ -0,0 +1,394 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteAssist.Diagnostic;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteAssist.Severity;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RouteAssistTest {
+
+    private static CamelCatalog catalog;
+
+    @TempDir
+    Path dir;
+
+    @BeforeAll
+    static void loadCatalog() {
+        catalog = new DefaultCamelCatalog();
+    }
+
+    private static String java(String configure) {
+        return """
+                import org.apache.camel.builder.RouteBuilder;
+
+                public class MyRoute extends RouteBuilder {
+                    @Override
+                    public void configure() throws Exception {
+                %s
+                    }
+                }
+                """.formatted(configure);
+    }
+
+    private static List<Diagnostic> errors(String file, String content) {
+        return RouteAssist.diagnostics(file, content, catalog, null).stream()
+                .filter(d -> d.severity() == Severity.ERROR).toList();
+    }
+
+    @Test
+    void aValidJavaRouteHasNoErrors() {
+        String src = java("""
+                        from("timer:tick?period=1000")
+                            .filter(simple("${header.foo} == 'bar'"))
+                                .log("Got ${body}")
+                                .to("seda:out");
+                """);
+        assertThat(errors("MyRoute.java", src)).isEmpty();
+    }
+
+    @Test
+    void anUnknownOptionOfAJavaEndpointIsReportedOnItsLine() {
+        String src = java("""
+                        from("timer:tick?peroid=1000")
+                            .to("seda:out");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(6);
+        assertThat(errors.get(0).message()).contains("timer: Unknown option 
'peroid'").contains("period");
+        assertThat(errors.get(0).format()).startsWith("Line 6: ");
+    }
+
+    @Test
+    void anOptionOfAUriBuiltOverSeveralLinesIsReportedOnItsOwnLine() {
+        String src = java("""
+                        from("timer:tick"
+                                + "?period=1000"
+                                + "&fixedRat=true")
+                            .to("seda:out");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(8);
+        assertThat(errors.get(0).message()).contains("Unknown option 
'fixedRat'");
+    }
+
+    @Test
+    void theEndpointDslIsCheckedAsTheUriItBuilds() {
+        String src = """
+                import org.apache.camel.builder.endpoint.EndpointRouteBuilder;
+
+                public class MyRoute extends EndpointRouteBuilder {
+                    @Override
+                    public void configure() throws Exception {
+                        from(timer("tick").period(1000))
+                            .to(file("out").fileExist("Overide"));
+                    }
+                }
+                """;
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(7);
+        assertThat(errors.get(0).message()).contains("Invalid enum value 
'Overide'").contains("Override");
+    }
+
+    @Test
+    void aConstantOfTheClassIsResolved() {
+        String src = """
+                import org.apache.camel.builder.RouteBuilder;
+
+                public class MyRoute extends RouteBuilder {
+                    private static final String IN = "timer:tick?peroid=5";
+
+                    @Override
+                    public void configure() throws Exception {
+                        from(IN).to("seda:out");
+                    }
+                }
+                """;
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(8);
+        assertThat(errors.get(0).message()).contains("Unknown option 
'peroid'");
+    }
+
+    @Test
+    void aSimpleExpressionThatDoesNotParse() {
+        String src = java("""
+                        from("timer:tick")
+                            .filter(simple("${header.foo} =="))
+                                .to("seda:out");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(7);
+        assertThat(errors.get(0).message()).startsWith("Simple syntax error");
+    }
+
+    @Test
+    void aSimpleExpressionOfTheFluentBuilder() {
+        String src = java("""
+                        from("timer:tick")
+                            .setBody().simple("${header.foo")
+                            .to("seda:out");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(7);
+        assertThat(errors.get(0).message()).startsWith("Simple syntax error");
+    }
+
+    @Test
+    void aTernaryWrittenAsLiteralText() {
+        String src = java("""
+                        from("timer:tick")
+                            .setBody(simple("${header.foo} > 5 ? big : small"))
+                            .to("seda:out");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(7);
+    }
+
+    @Test
+    void aToWithAnExpressionNeedsToD() {
+        String src = java("""
+                        from("timer:tick")
+                            .to("seda:${header.queue}");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        
assertThat(errors.get(0).message()).contains("${header.queue}").contains("toD");
+        // toD is what evaluates the uri per message
+        assertThat(errors("MyRoute.java", java("""
+                        from("timer:tick")
+                            .toD("seda:${header.queue}");
+                """))).isEmpty();
+    }
+
+    @Test
+    void aProducerOnlyComponentCannotBeConsumed() {
+        String src = java("""
+                        from("log:foo")
+                            .to("seda:out");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).message()).contains("log is a producer-only 
component");
+    }
+
+    @Test
+    void severalEndpointsInOneUri() {
+        String src = java("""
+                        from("timer:tick")
+                            .to("seda:a,seda:b");
+                """);
+        List<Diagnostic> errors = errors("MyRoute.java", src);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).message()).contains("names 
several").contains("multicast");
+    }
+
+    @Test
+    void aDirectEndpointNoRouteConsumes() throws Exception {
+        Files.writeString(dir.resolve("other.camel.yaml"), """
+                - route:
+                    from:
+                      uri: direct:present
+                      steps:
+                        - log: "${body}"
+                """);
+        String src = java("""
+                        from("timer:tick")
+                            .to("direct:present")
+                            .to("direct:missing")
+                            .to("direct:local");
+
+                        from("direct:local").log("local");
+                """);
+        List<Diagnostic> errors = RouteAssist.diagnostics("MyRoute.java", src, 
catalog, dir).stream()
+                .filter(d -> d.severity() == Severity.ERROR).toList();
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(8);
+        assertThat(errors.get(0).message()).contains("sends to direct:missing, 
and no route consumes it")
+                .contains("from(\"direct:missing\")");
+    }
+
+    @Test
+    void aPartTheParserCannotReadIsNotChecked() {
+        String src = java("""
+                        from("timer:tick")
+                            .process(e -> e.getMessage().setBody("x"))
+                            .to("seda:out?" + options());
+                """).replace("    @Override", "    String options() { return 
\"size=1\"; }\n\n    @Override");
+        List<Diagnostic> all = RouteAssist.diagnostics("MyRoute.java", src, 
catalog, null);
+        assertThat(all).noneMatch(d -> d.severity() == Severity.ERROR);
+        assertThat(all).anyMatch(d -> d.severity() == Severity.INFO && 
d.message().startsWith("not checked: "));
+    }
+
+    @Test
+    void aJavaFileWithoutARouteBuilderIsNotRead() {
+        assertThat(RouteAssist.supports("Foo.java", "public class Foo { 
}")).isFalse();
+        assertThat(RouteAssist.diagnostics("Foo.java", "public class Foo { }", 
catalog, null)).isEmpty();
+    }
+
+    @Test
+    void aValidXmlRouteHasNoErrors() {
+        String xml = """
+                <routes xmlns="http://camel.apache.org/schema/xml-io";>
+                    <route id="a">
+                        <from uri="timer:tick?period=1000"/>
+                        <choice>
+                            <when>
+                                <simple>${header.foo} == 'bar'</simple>
+                                <to uri="seda:bar"/>
+                            </when>
+                        </choice>
+                    </route>
+                </routes>
+                """;
+        assertThat(RouteAssist.supports("routes.xml", xml)).isTrue();
+        assertThat(errors("routes.xml", xml)).isEmpty();
+    }
+
+    @Test
+    void anUnknownOptionOfAnXmlEndpointIsReportedOnItsLine() {
+        String xml = """
+                <routes xmlns="http://camel.apache.org/schema/xml-io";>
+                    <route>
+                        <from uri="timer:tick?peroid=1000"/>
+                        <to uri="seda:out"/>
+                    </route>
+                </routes>
+                """;
+        List<Diagnostic> errors = errors("routes.xml", xml);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(3);
+        assertThat(errors.get(0).message()).contains("Unknown option 
'peroid'");
+    }
+
+    @Test
+    void aSimplePredicateOfAnXmlRouteThatDoesNotParse() {
+        String xml = """
+                <camel>
+                    <route>
+                        <from uri="timer:tick"/>
+                        <filter>
+                            <simple>${header.foo} ==</simple>
+                            <to uri="seda:out"/>
+                        </filter>
+                    </route>
+                </camel>
+                """;
+        List<Diagnostic> errors = errors("camel.xml", xml);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(4);
+        assertThat(errors.get(0).message()).startsWith("Simple syntax error");
+    }
+
+    @Test
+    void anElementTheXmlDslDoesNotHave() {
+        String xml = """
+                <routes xmlns="http://camel.apache.org/schema/xml-io";>
+                    <route>
+                        <from uri="timer:tick"/>
+                        <fitler>
+                            <simple>${header.foo}</simple>
+                        </fitler>
+                    </route>
+                </routes>
+                """;
+        List<Diagnostic> errors = errors("routes.xml", xml);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).line()).isEqualTo(4);
+        assertThat(errors.get(0).message()).contains("fitler");
+    }
+
+    @Test
+    void aSpringXmlFileIsNotRead() {
+        String xml = """
+                <beans xmlns="http://www.springframework.org/schema/beans";>
+                    <camelContext 
xmlns="http://camel.apache.org/schema/spring";>
+                        <route><from uri="timer:tick?peroid=1"/><to 
uri="seda:x"/></route>
+                    </camelContext>
+                </beans>
+                """;
+        assertThat(RouteAssist.supports("beans.xml", xml)).isFalse();
+    }
+
+    @Test
+    void theNodesOfALine() {
+        String src = java("""
+                        from("timer:tick")
+                            .filter(simple("${header.foo} == 'bar'"))
+                                .to("seda:out");
+                """);
+        List<RouteNodes.Node> nodes = RouteAssist.nodes("MyRoute.java", src, 
catalog, null);
+        List<RouteNodes.Node> filter = RouteNodes.at(nodes, 7);
+        assertThat(filter).extracting(RouteNodes.Node::kind)
+                .containsExactly(RouteNodes.Kind.STEP, 
RouteNodes.Kind.EXPRESSION);
+        assertThat(filter.get(1).predicate()).isTrue();
+        assertThat(filter.get(1).text()).isEqualTo("${header.foo} == 'bar'");
+        List<RouteNodes.Node> to = RouteNodes.at(nodes, 8);
+        assertThat(to).extracting(RouteNodes.Node::uri).contains("seda:out");
+        assertThat(to.get(0).parents()).containsExactly("filter");
+    }
+
+    @Test
+    void whatTheRuntimeAcceptsIsNotReported() {
+        // found by running the checks over the Camel examples and Camel's own 
tests: none of these is wrong
+        String src = java("""
+                        from("kamelet:myTemplate?greeting=hello")
+                            .to("jms:queue:orders?exchangePattern=InOut")
+                            
.to("kafka:out?additional-properties[transactional.id]=1234")
+                            .setBody(simple("resource:classpath:script.txt"))
+                            .setBody(simple("${type:com.acme.Codes.OK}"))
+                            .setBody(simple("${function(greet)}"));
+                """);
+        assertThat(errors("MyRoute.java", src)).isEmpty();
+    }
+
+    @Test
+    void aBeansXmlOfCdiIsNotRead() {
+        assertThat(RouteAssist.supports("beans.xml", "<beans 
bean-discovery-mode=\"all\" version=\"4.0\"/>")).isFalse();
+        assertThat(RouteAssist.supports("beans.xml", """
+                <beans xmlns="https://jakarta.ee/xml/ns/jakartaee"; 
bean-discovery-mode="all"/>
+                """)).isFalse();
+        assertThat(RouteAssist.supports("camel.xml", """
+                <beans>
+                    <route><from uri="timer:tick"/><to uri="seda:out"/></route>
+                </beans>
+                """)).isTrue();
+    }
+
+    @Test
+    void anUnknownValueOfTheParserIsNotAPlaceholder() {
+        
assertThat(RouteModel.hasUnknownValue("mina:tcp://localhost:?{getPort()}")).isTrue();
+        
assertThat(RouteModel.hasUnknownValue("kafka:orders?{{kafka.options}}")).isFalse();
+        assertThat(RouteModel.hasUnknownValue("kafka:orders")).isFalse();
+    }
+}

Reply via email to