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(); + } +}
