You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/01 07:16:21 UTC
(camel) branch main updated: CAMEL-25208: camel-jbang - Camel validation, quick doc and completion for Java and XML DSL routes (#27172)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 8045e74b042c CAMEL-25208: camel-jbang - Camel validation, quick doc and completion for Java and XML DSL routes (#27172)
8045e74b042c is described below
commit 8045e74b042c8beb7b728322fb98616ad4dbeae4
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Thu Oct 1 09:16:15 2026 +0200
CAMEL-25208: camel-jbang - Camel validation, quick doc and completion for Java and XML DSL routes (#27172)
The editing help of the TUI Source tab (validation, quick doc, completion) was for the YAML DSL only. XML files were only checked to be well formed, and Java files only by javac, which cannot see inside the strings, where most Camel mistakes are (endpoint uris and their options, simple expressions, `to` vs `toD`).
Instead of repeating the YAML line patterns per DSL, this adds one model-based layer in camel-jbang-core, usable by any tool (TUI, MCP, CLI). It is shaped like the operations of a language server, without the protocol.
### Core (camel-jbang-core)
- **`RouteModel`** reads a Java DSL source (LwJavaParser) or an XML DSL source (camel-xml-io `ModelParser`) into the Camel model with lines, without compiling or running it. For Java the endpoint DSL and constants are resolved.
- **`RouteNodes`** lists the steps, endpoints and expressions by line. An expression is put on the line its text is on: the `<simple>` element, or a `simple("...")` on a later line.
- **`ModelChecks`** runs the checks the YAML DSL has, on the model:
- endpoint uris against the catalog;
- simple syntax (predicate or expression by the EIP);
- a `to` whose uri holds `${...}`, which should be a `toD`;
- a producer-only component as `from`;
- several endpoints in one uri;
- `direct:`/`seda:` endpoints no route consumes;
- XML elements the XML DSL does not have.
A value the parser could not know is left unchecked.
- **`RouteAssist`** is the entry point: `diagnostics(...)` and `nodes(...)`.
- The per-uri and per-expression checks of `EndpointChecks` and `SimpleChecks` are shared with the YAML checks.
### Validation (CLI, MCP)
`SourceValidator.validate` adds these checks for `.java` files (after javac) and Camel `.xml` files. That covers `camel validate source`, `camel_validate_source` and `camel_write_file`. `camel_validate_source` also lists what the parser could not read (`notChecked`).
### Precision
The checks were run over about 3700 route files: camel-examples, camel-jbang-examples, the Spring Boot and Quarkus examples, camelinaction2, and Camel's own tests. There were no crashes, and the run takes about 10 seconds.
The false positives this found are fixed in the shared code, so the YAML checks lose them too:
- `exchangePattern` on a `to` (SendProcessor reads it from the uri);
- kamelet parameters;
- map options written `name[key]`;
- `simple("resource:...")` scripts;
- application classes and custom simple functions;
- CDI `beans.xml` files.
### TUI Source tab
- Java and XML routes are checked while typing and on save, with the problems marked on their lines (F9 jumps to the next). An XML file with problems is not saved, as for YAML. A Java file is saved and its problems are reported.
- Quick doc for Java and XML: the component and options of an endpoint, the EIP of a step, the language of its expression. It also works when no integration is selected.
- Tab completion in endpoint uris, in Java strings and XML `uri` attributes: component names, options and their values.
- The completion popup puts an exact or prefix match first (`seda` before `hazelcast-seda`, YAML too). Option docs keep their first sentence whole (they were cut at the dot of "i.e.").
- Tested live in the TUI through its MCP server; the problems found that way are fixed in this PR.
### Docs
TUI page, F1 help, devtools (`camel validate`), MCP tool table, and an upgrade guide note (4.23): files that validated before can now report errors.
Simple language completion is a follow-up: CAMEL-25219.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 8 +
.../modules/ROOT/pages/camel-jbang-devtools.adoc | 8 +-
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 7 +-
.../modules/ROOT/pages/camel-jbang-tui.adoc | 20 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 15 +-
.../dsl/jbang/core/commands/ai/EndpointChecks.java | 125 ++++--
.../dsl/jbang/core/commands/ai/ModelChecks.java | 246 ++++++++++++
.../dsl/jbang/core/commands/ai/RouteAssist.java | 181 +++++++++
.../dsl/jbang/core/commands/ai/RouteModel.java | 281 ++++++++++++++
.../dsl/jbang/core/commands/ai/RouteNodes.java | 347 +++++++++++++++++
.../dsl/jbang/core/commands/ai/SimpleChecks.java | 80 ++--
.../jbang/core/commands/ai/SourceValidator.java | 36 +-
.../jbang/core/commands/ai/RouteAssistTest.java | 423 +++++++++++++++++++++
.../commands/ai/SourceValidatorRoutesTest.java | 113 ++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 2 +-
.../jbang/core/commands/tui/AutocompletePopup.java | 10 +
.../dsl/jbang/core/commands/tui/EipDocSupport.java | 17 +-
.../core/commands/tui/EndpointUriContext.java | 121 ++++++
.../jbang/core/commands/tui/SourceEditAssist.java | 161 +++++++-
.../dsl/jbang/core/commands/tui/SourceTab.java | 23 +-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 185 ++++++++-
.../src/main/resources/tui/help/source.md | 15 +-
.../commands/tui/EndpointUriCompletionTest.java | 249 ++++++++++++
.../commands/tui/SourceRouteValidationTest.java | 233 ++++++++++++
24 files changed, 2828 insertions(+), 78 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index bb817e1253fc..18fb318551ea 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -1598,6 +1598,14 @@ named as written.
`--dep=camel-groovy`; before, the file was silently ignored unless the dependency was added. The dependency is
also written to the run settings, so `camel export` includes it.
+`camel validate source`, and the `camel_validate_source` and `camel_write_file` MCP tools, now check the routes of
+Java and XML DSL files as they check YAML routes: the endpoint URIs against the catalog (also those built with the
+endpoint DSL or from a constant), simple expressions, and a `to` whose URI holds `+${...}+` and should be a `toD`.
+A Java file used to be checked only by the compiler and an XML file only for being well formed, so a file that
+validated before can now report errors, and `camel_write_file` refuses to write it until they are fixed. In the
+TUI Source tab an XML route file with such problems is not saved, as a YAML
+file; a Java file is saved and its problems are shown.
+
=== camel-jbang (MCP servers)
The Camel authoring tools for AI agents are now defined once, in `camel-jbang-core`, and exposed under the
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
index 44a580ed4062..5b292616a877 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
@@ -425,9 +425,13 @@ Validation success (files:2)
`camel validate yaml` checks the YAML DSL schema, then the endpoint URIs, Simple expressions and bean references
against the Camel catalog (`--catalog=false` for the schema only); `--canonical` validates against the canonical
schema and reports the deprecated compact notation. `camel validate source` validates any file `camel run` would
-load: YAML routes, `.properties` files (the `camel.*` keys), Java (compiles it), XSLT and XML.
+load: YAML routes, `.properties` files (the `camel.*` keys), Java (compiles it), XSLT and XML. The routes of a Java
+or XML DSL file are also read into the Camel model, without compiling or running them, and checked as YAML routes
+are: the endpoint URIs (with the endpoint DSL and constants resolved), Simple expressions, and a `to` whose URI
+holds `+${...}+` and should be a `toD` - what the compiler cannot see inside the strings. A part of a Java route the
+parser cannot work out (a value computed at runtime) is left unchecked, and `camel_validate_source` lists it.
As the routes of an application are spread over the files of its directory, `camel validate source` also reports a
-`direct:` or `seda:` endpoint a YAML route sends to that no route consumes, in that file or in the YAML, Java and
+`direct:` or `seda:` endpoint a YAML, Java or XML route sends to that no route consumes, in that file or in the YAML, Java and
XML route files of its directory and its subdirectories: with `direct:` the route would fail to start, with `seda:`
the messages would be queued and never read. The check stays quiet when it cannot be certain: an endpoint only known
at runtime (a property placeholder, an expression), route templates and Kamelets, and a directory that is only a part
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 5df892049106..e4f19d052b4c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -317,8 +317,11 @@ project `directory` as an argument, the runtime tools take the integration `name
`not defined in the schema` validation error.
| `camel_validate_source`
-| Validates Camel YAML DSL or `.properties` source without writing: the YAML DSL schema (a misspelled option
- such as `logLevel` instead of `loggingLevel`), endpoint URIs, simple expressions, and `camel.*` options.
+| Validates Camel YAML, Java or XML DSL or `.properties` source without writing: the YAML DSL schema (a
+ misspelled option such as `logLevel` instead of `loggingLevel`), endpoint URIs, simple expressions, and
+ `camel.*` options. The routes of a Java or XML file are read into the Camel model without compiling them and
+ get the same checks (the endpoint DSL and constants resolved, a `to` that needs `toD`); what the parser could
+ not read is listed under `notChecked`.
Takes the content, or reads the file from the project directory. With `camelVersion` (or the version of the
selected integration in the TUI) the catalog and the YAML DSL schema of that Camel version answer, the schema
read from its `camel-yaml-dsl` jar; without it the CLI's own, with nothing to download.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index a5726dba200b..4552e5cefb1e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -191,7 +191,7 @@ Additional features:
=== Source Editor
-Press *F4* to enter edit mode for YAML routes, `application.properties`, and other local files.
+Press *F4* to enter edit mode for YAML, Java and XML routes, `application.properties`, and other local files.
The editor provides a plain-text editing experience with features designed for working with
Camel routes:
@@ -206,6 +206,13 @@ Camel routes:
and Spring Boot auto-configuration properties (`+server.*+`, `+spring.*+`, `+management.*+`, etc.)
resolved from starter JARs in the local Maven repository — works even for stopped projects
** In YAML DSL routes: EIP names, component URIs, option keys and values
+** In Java and XML DSL routes: in the endpoint URI of `from`, `to`, `toD`, `wireTap`, `enrich`, `pollEnrich`
+ and `poll` (the string given to them in Java, their `uri` attribute in XML), the component name, the
+ endpoint options and their values
+
+A quick documentation panel at the bottom shows the documentation of the line the cursor is on: for YAML, Java
+and XML routes the component and options of an endpoint, the EIP of a step and the language of its expression.
+Java routes are read by the Java DSL parser without compiling them, so constants and the endpoint DSL are resolved.
The editor shows *gutter change markers* -- a green background on line numbers that have been
modified or added since the file was opened. This gives an at-a-glance view of what you've changed.
@@ -227,6 +234,12 @@ file is not saved and errors are displayed in a popup. This catches mistakes ear
* *Simple expression validation* -- validates Simple language expressions for syntax errors,
detecting the correct context (predicate vs expression) from the parent EIP
+Java and XML routes get the same Camel checks, on the Camel model the routes are read into: the endpoint URIs
+(also those built with the endpoint DSL, or from a constant), Simple expressions, and a `to` whose URI holds
+`+${...}+` and should be a `toD`. Their problems are marked on their lines while you type (*F9* jumps to the next).
+An XML file with problems is not saved, as a YAML file; a Java file is saved and its problems are said, as it is
+the application's code, and a check that cannot know something (a value computed at runtime) must not stop you.
+
Validation can be toggled off in the Settings (F2 > Settings).
== Activity
@@ -1444,8 +1457,9 @@ worst case is a wrong route file in a folder you are watching.
Before writing, an agent can check its content with `camel_validate_source`, which runs the same checks as
the Source tab's save: YAML routes against the Camel YAML DSL schema (a misspelled option such as `logLevel`
-instead of `loggingLevel` is reported) plus endpoint URIs and simple expressions, and `.properties` files
-against the catalog of `camel.*` and Spring Boot options. `camel_write_file` runs that validation itself and
+instead of `loggingLevel` is reported) plus endpoint URIs and simple expressions, Java and XML routes read into
+the Camel model with the same endpoint and simple checks, and `.properties` files against the catalog of
+`camel.*` and Spring Boot options. `camel_write_file` runs that validation itself and
refuses to write an invalid file, returning the errors instead, so a model fixes them rather than the user
finding them in the log after the reload. Both tools are part of the core
tool set, so local models (Ollama) get them as well.
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index eb0fce3fbe07..064ffd66967e 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -146,7 +146,7 @@ public final class AuthoringTools {
}));
registry.accept(tool("camel_validate_source",
- "Validates Camel YAML DSL or .properties source without writing: schema (misspelled options such as "
+ "Validates Camel YAML/Java/XML DSL or .properties source without writing: schema (misspelled options such as "
+ "logLevel instead of loggingLevel), endpoint URIs, simple expressions, "
+ "camel.* options. Use on content before writing it, or on an existing "
+ "file (no content) to explain a reload error.")
@@ -497,6 +497,19 @@ public final class AuthoringTools {
result.put("valid", errors.isEmpty());
result.put("file", file);
result.put("errors", new JsonArray(errors));
+ if (RouteAssist.supports(file, content)) {
+ // the parts of a Java or XML route the parser could not read, so the checks did not see (CAMEL-25208)
+ JsonArray notChecked = new JsonArray();
+ for (RouteAssist.Diagnostic d : RouteAssist.diagnostics(file, content, ctx.catalog(), null,
+ RouteAssist.javaSources(dir), false)) {
+ if (d.severity() == RouteAssist.Severity.INFO) {
+ notChecked.add(d.format());
+ }
+ }
+ if (!notChecked.isEmpty()) {
+ result.put("notChecked", notChecked);
+ }
+ }
result.put("message", errors.isEmpty()
? "The source is valid"
: errors.size() + " problem(s) found; fix them before writing the file");
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..c63fcc273709 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..1f7c59a064ac
--- /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,246 @@
+/*
+ * 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 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 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 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;
+ }
+ for (int i = lineIdx; i < lines.length && i < lineIdx + 10; i++) {
+ if (hasOption(lines[i], name)) {
+ if (i != lineIdx) {
+ answer.put(name, i);
+ }
+ break;
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Whether the line has the option name= as an option of a uri: after ?, &, a quote, a space or a parenthesis.
+ */
+ private static boolean hasOption(String line, String name) {
+ String key = name + "=";
+ for (int i = line.indexOf(key); i >= 0; i = line.indexOf(key, i + 1)) {
+ if (i > 0 && "?&\"' \t(".indexOf(line.charAt(i - 1)) >= 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 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..06a1d1a0944a
--- /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.withExpressionLines(RouteNodes.of(model, catalog), content);
+ 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.withExpressionLines(RouteNodes.of(model, catalog), content) : 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..57c60bf10132
--- /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,281 @@
+/*
+ * 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');
+ msg = nl > 0 ? msg.substring(0, nl).trim() : msg.trim();
+ // the parser appends where it is ("(position: TEXT seen ...</roues>... @11:9)"), which the line already says
+ int position = msg.indexOf(" (position:");
+ return position > 0 ? msg.substring(0, position) : msg;
+ }
+
+ /** 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..df9301f737b8
--- /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,347 @@
+/*
+ * 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 with each expression on the line its text is on, which is often not the line of its step: the
+ * {@code <simple>} element under a {@code <when>}, the {@code simple("...")} of a Java call written over several
+ * lines. The first line at or after the step's that has the start of the text (or, in XML, the element of its
+ * language) is taken; the step's line stays when there is none.
+ */
+ public static List<Node> withExpressionLines(List<Node> nodes, String content) {
+ String[] lines = content.split("\n", -1);
+ List<Node> answer = new ArrayList<>(nodes.size());
+ for (Node n : nodes) {
+ if (n.kind() == Kind.EXPRESSION && n.line() > 0 && n.text() != null && !n.text().isBlank()) {
+ int found = expressionLine(lines, n);
+ if (found > 0 && found != n.line()) {
+ n = new Node(
+ n.kind(), found, n.eip(), n.uri(), n.language(), n.text(), n.option(), n.predicate(),
+ n.parents());
+ }
+ }
+ answer.add(n);
+ }
+ answer.sort(Comparator.comparingInt(Node::line));
+ return answer;
+ }
+
+ private static int expressionLine(String[] lines, Node n) {
+ String text = n.text().strip();
+ // the start of the text up to what a DSL may write escaped (a quote, a < or & in XML)
+ int cut = 0;
+ while (cut < text.length() && cut < 20 && "\"'<>&\\\n".indexOf(text.charAt(cut)) < 0) {
+ cut++;
+ }
+ String start = text.substring(0, cut);
+ String tag = "<" + n.language();
+ for (int i = n.line() - 1; i < lines.length && i < n.line() - 1 + 15; i++) {
+ if (start.length() >= 3 && lines[i].contains(start) || lines[i].contains(tag + ">")
+ || lines[i].contains(tag + " ")) {
+ return i + 1;
+ }
+ }
+ return 0;
+ }
+
+ /** 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/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index f0102539d63d..0a8c9248ca1e 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -140,17 +140,49 @@ public final class SourceValidator {
if (directory != null && !msgs.isEmpty()) {
msgs = withSiblingClassHints(msgs, BeanDeclarations.scan(directory, fileName));
}
- return msgs;
+ // what the compiler cannot see: the endpoint uris and simple expressions inside the strings (CAMEL-25208)
+ return withRouteChecks(msgs, fileName, content, catalog, directory, checkConsumers);
}
if (name.endsWith(".xsl") || name.endsWith(".xslt")) {
return validateXslt(content);
}
if (name.endsWith(".xml")) {
- return validateXml(content);
+ List<String> msgs = validateXml(content);
+ // a well formed file of the XML DSL: its routes against the XML DSL and the catalog (CAMEL-25208)
+ return msgs.isEmpty() ? withRouteChecks(msgs, fileName, content, catalog, directory, checkConsumers) : msgs;
}
return List.of();
}
+ /**
+ * Adds the errors of the Camel checks of a Java or XML DSL source ({@link RouteAssist}) to the messages, leaving
+ * out one on a line that already has a message.
+ */
+ private static List<String> withRouteChecks(
+ List<String> msgs, String fileName, String content, CamelCatalog catalog, Path directory,
+ boolean checkConsumers) {
+ if (!RouteAssist.supports(fileName, content)) {
+ return msgs;
+ }
+ List<String> answer = new ArrayList<>(msgs);
+ Set<String> linesWithMessages = new HashSet<>();
+ for (String m : msgs) {
+ Matcher lm = MESSAGE_LINE.matcher(m);
+ if (lm.find()) {
+ linesWithMessages.add(lm.group(1));
+ }
+ }
+ for (RouteAssist.Diagnostic d : RouteAssist.diagnostics(fileName, content, catalog, directory,
+ RouteAssist.javaSources(directory), checkConsumers && directory != null)) {
+ if (d.severity() == RouteAssist.Severity.ERROR && !linesWithMessages.contains(Integer.toString(d.line()))) {
+ answer.add(d.format());
+ }
+ }
+ return answer;
+ }
+
+ private static final Pattern MESSAGE_LINE = Pattern.compile("^Line (\\d+)\\b");
+
/**
* Validates Camel YAML DSL source: the YAML DSL schema first, then endpoint URIs and simple expressions against the
* catalog. The schema is the one of the catalog's Camel version: the CLI's own, or for a catalog of another version
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..94acb8b75f63
--- /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,423 @@
+/*
+ * 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 aSimpleExpressionOnTheLineAfterItsStep() {
+ 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(8);
+ }
+
+ @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);
+ // the line of the <simple> element, not of the filter it belongs to
+ assertThat(errors.get(0).line()).isEqualTo(5);
+ 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 anXmlFileThatIsNotWellFormed() {
+ String xml = """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ </route>
+ </roues>
+ """;
+ List<Diagnostic> errors = errors("routes.xml", xml);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).line()).isEqualTo(5);
+ assertThat(errors.get(0).message()).contains("</roues>").doesNotContain("position:");
+ }
+
+ @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();
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorRoutesTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorRoutesTest.java
new file mode 100644
index 000000000000..f28bcf158b74
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorRoutesTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The Camel checks of Java and XML DSL routes in the validation shared by the TUI, camel_validate_source and camel
+ * validate (CAMEL-25208).
+ */
+class SourceValidatorRoutesTest {
+
+ private static CamelCatalog catalog;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ private static final String JAVA = """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class MyRoute extends RouteBuilder {
+ @Override
+ public void configure() throws Exception {
+ from("timer:tick?peroid=1000")
+ .process(e -> e.getMessage().setBody("hello"))
+ .to("seda:out");
+ }
+ }
+ """;
+
+ @Test
+ void theEndpointsOfAJavaRouteAreChecked() {
+ List<String> errors = SourceValidator.validate("MyRoute.java", JAVA, catalog, null, null);
+ assertThat(errors).containsExactly(
+ "Line 6: timer: Unknown option 'peroid'. Did you mean: [period]");
+ }
+
+ @Test
+ void aJavaCompileErrorKeepsItsLine() {
+ String src = JAVA.replace(".to(\"seda:out\");", ".to(\"seda:out\")");
+ List<String> errors = SourceValidator.validate("MyRoute.java", src, catalog, null, null);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors.get(0)).contains("';' expected");
+ // the Camel checks still run on what the parser reads
+ assertThat(errors).anyMatch(e -> e.contains("Unknown option 'peroid'"));
+ }
+
+ @Test
+ void theRoutesOfAnXmlFileAreChecked() {
+ String xml = """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ <to uri="seda:${header.queue}"/>
+ </route>
+ </routes>
+ """;
+ List<String> errors = SourceValidator.validate("routes.xml", xml, catalog, null, null);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).startsWith("Line 4: ").contains("toD");
+ }
+
+ @Test
+ void anXmlFileThatIsNotWellFormedIsReportedAsSuch() {
+ String xml = "<routes><route><from uri=\"timer:tick\"/></routes>";
+ List<String> errors = SourceValidator.validate("routes.xml", xml, catalog, null, null);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).contains("XML is not well formed");
+ }
+
+ @Test
+ void theValidateToolSaysWhatWasNotChecked() throws Exception {
+ String src = JAVA.replace(".to(\"seda:out\");", ".to(\"seda:out?\" + options());")
+ .replace(" @Override", " String options() { return \"size=1\"; }\n\n @Override");
+ Map<String, String> args = new HashMap<>();
+ args.put("file", "MyRoute.java");
+ args.put("content", src);
+ JsonObject result = (JsonObject) Jsoner.deserialize(
+ String.valueOf(ToolRegistry.execute("camel_validate_source", new ToolContext(), args)));
+ assertThat((JsonArray) result.get("errors")).hasSize(1);
+ JsonArray notChecked = (JsonArray) result.get("notChecked");
+ assertThat(notChecked).hasSize(1);
+ assertThat(notChecked.getString(0)).startsWith("Line 10: not checked: ").contains("options()");
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 7f3b43bc2e86..2948c1e2fd00 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -110,7 +110,7 @@ public class AuthoringTools {
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
- description = "Validates Camel YAML DSL or .properties source without writing: schema (misspelled options "
+ description = "Validates Camel YAML/Java/XML DSL or .properties source without writing: schema (misspelled options "
+ "such as logLevel instead of loggingLevel), endpoint URIs, simple expressions, camel.* "
+ "options, and how each bean under beans: is created (the properties a class built through "
+ "its builder() accepts, the ways to create a class with no constructor). Use on content "
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
index faa66df23bb4..8de140e54531 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import dev.tamboui.layout.Rect;
@@ -535,10 +536,19 @@ class AutocompletePopup {
filteredItems.add(item);
}
}
+ // what was typed exactly first, then what starts with it: seda before hazelcast-seda
+ filteredItems.sort(Comparator.comparingInt(item -> rank(item.key(), f)));
}
listState.select(filteredItems.isEmpty() ? null : 0);
}
+ private static int rank(String key, String filter) {
+ if (key.equalsIgnoreCase(filter)) {
+ return 0;
+ }
+ return key.regionMatches(true, 0, filter, 0, filter.length()) ? 1 : 2;
+ }
+
private static boolean matchesLabel(String group, String filter) {
if (group == null || group.isEmpty()) {
return false;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EipDocSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EipDocSupport.java
index 0372937f204b..acb7732a2d33 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EipDocSupport.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EipDocSupport.java
@@ -381,11 +381,26 @@ final class EipDocSupport {
return Math.max(0, fallback);
}
+ /**
+ * The period that ends the first sentence: one followed by a space or the end of the text, and not the period of an
+ * abbreviation (e.g., i.e.) - not the dot of (i.e., a number such as 0.5, or a name such as file.txt.
+ */
+ static int sentenceEnd(String text) {
+ for (int i = text.indexOf('.'); i >= 0; i = text.indexOf('.', i + 1)) {
+ boolean end = i + 1 == text.length() || Character.isWhitespace(text.charAt(i + 1));
+ if (end && !text.regionMatches(true, Math.max(0, i - 3), "e.g", 0, 3)
+ && !text.regionMatches(true, Math.max(0, i - 3), "i.e", 0, 3)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
static String truncateText(String text, int maxLen) {
if (text == null) {
return "";
}
- int dot = text.indexOf('.');
+ int dot = sentenceEnd(text);
if (dot > 0 && dot < maxLen) {
return text.substring(0, dot + 1);
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointUriContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointUriContext.java
new file mode 100644
index 000000000000..fda7068659db
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointUriContext.java
@@ -0,0 +1,121 @@
+/*
+ * 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.tui;
+
+import java.util.Set;
+
+/**
+ * The endpoint uri the cursor is in, read from the line alone so it works on a line being typed, which does not parse
+ * (CAMEL-25208): in Java the string literal given to a DSL method, to("kafka:orders?bro| is in to with kafka:orders?bro
+ * before the cursor; in XML the uri attribute of an element, <to uri="kafka:orders?bro| is in to (its &amp; read
+ * as &).
+ *
+ * @param call the DSL method (Java) or element (XML) of the uri, such as to or from
+ * @param before the text of the uri before the cursor
+ */
+record EndpointUriContext(String call, String before) {
+
+ /** The DSL methods and elements whose argument or attribute is an endpoint uri. */
+ static final Set<String> ENDPOINT_CALLS = Set.of("from", "to", "toD", "wireTap", "enrich", "pollEnrich", "poll");
+ /** The ones of them that consume from their endpoint. */
+ static final Set<String> CONSUMER_CALLS = Set.of("from", "pollEnrich", "poll");
+
+ /** The context at the cursor in a Java line, or null when it is not in a string literal given to a method. */
+ static EndpointUriContext inJava(String line, int col) {
+ if (line == null || col < 0 || col > line.length()) {
+ return null;
+ }
+ int open = -1;
+ for (int i = 0; i < col; i++) {
+ char c = line.charAt(i);
+ if (open >= 0) {
+ if (c == '\\') {
+ i++;
+ } else if (c == '"') {
+ open = -1;
+ }
+ } else if (c == '"') {
+ open = i;
+ } else if (c == '/' && i + 1 < line.length() && line.charAt(i + 1) == '/') {
+ // a line comment: no string literal after it
+ return null;
+ }
+ }
+ if (open < 0) {
+ return null;
+ }
+ String head = line.substring(0, open).stripTrailing();
+ if (!head.endsWith("(")) {
+ return null;
+ }
+ head = head.substring(0, head.length() - 1).stripTrailing();
+ int start = head.length();
+ while (start > 0 && Character.isJavaIdentifierPart(head.charAt(start - 1))) {
+ start--;
+ }
+ String call = head.substring(start);
+ if (call.isEmpty()) {
+ return null;
+ }
+ return new EndpointUriContext(call, line.substring(open + 1, col));
+ }
+
+ /** The context at the cursor in an XML line, or null when it is not in the uri attribute of an element. */
+ static EndpointUriContext inXml(String line, int col) {
+ if (line == null || col < 0 || col > line.length()) {
+ return null;
+ }
+ String head = line.substring(0, col);
+ int attr = Math.max(head.lastIndexOf("uri=\""), head.lastIndexOf("uri='"));
+ if (attr < 0 || attr > 0 && !Character.isWhitespace(head.charAt(attr - 1))) {
+ return null;
+ }
+ char quote = head.charAt(attr + 4);
+ String value = head.substring(attr + 5);
+ if (value.indexOf(quote) >= 0) {
+ // the attribute is closed before the cursor
+ return null;
+ }
+ int lt = head.lastIndexOf('<', attr);
+ if (lt < 0 || head.indexOf('>', lt) >= 0 && head.indexOf('>', lt) < attr) {
+ return null;
+ }
+ int start = lt + 1;
+ int end = start;
+ while (end < head.length() && (Character.isLetterOrDigit(head.charAt(end)) || head.charAt(end) == ':'
+ || head.charAt(end) == '-')) {
+ end++;
+ }
+ String element = head.substring(start, end);
+ // a namespace prefix: <camel:to uri="...
+ element = element.substring(element.indexOf(':') + 1);
+ if (element.isEmpty()) {
+ return null;
+ }
+ return new EndpointUriContext(element, value.replace("&", "&"));
+ }
+
+ /** Whether the literal is the endpoint uri of a DSL method. */
+ boolean isEndpoint() {
+ return ENDPOINT_CALLS.contains(call);
+ }
+
+ /** consumer or producer, as the completions of the endpoint options are filtered. */
+ String role() {
+ return CONSUMER_CALLS.contains(call) ? "consumer" : "producer";
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index ca1c8e260148..c53c171f4427 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -29,13 +29,17 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
+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.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteAssist;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteNodes;
import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator;
import org.apache.camel.tooling.model.BaseOptionModel;
import org.apache.camel.tooling.model.ComponentModel;
@@ -76,6 +80,17 @@ final class SourceEditAssist {
private final Map<String, Map<String, BaseOptionModel>> languageOptionsCache = new HashMap<>();
private final Map<String, Map<String, BaseOptionModel>> dataformatOptionsCache = new HashMap<>();
+ // Java sources of the project, for the constants of the Java route checks (read lazily, refreshed now and then)
+ private static final long JAVA_SOURCES_TTL_MS = 10_000;
+ private Map<String, Supplier<String>> javaSourcesCache;
+ private long javaSourcesCacheTime;
+ private Path javaSourcesCacheDir;
+
+ // the nodes of the route file last read for its quick doc
+ private List<RouteNodes.Node> routeNodesCache = List.of();
+ private String routeNodesContent;
+ private Path routeNodesFile;
+
// Component name completion cache (keyed by catalog version)
private String componentsCatalogVersion;
private List<AutocompletePopup.CompletionItem> consumerComponents;
@@ -862,7 +877,7 @@ final class SourceEditAssist {
if (!context.startsWith("yaml:")) {
return List.of();
}
- CamelCatalog catalog = getCatalog();
+ CamelCatalog catalog = validationCatalog();
if (catalog == null) {
return List.of();
}
@@ -931,7 +946,7 @@ final class SourceEditAssist {
}
List<AutocompletePopup.CompletionItem> provideComponentNameCompletions(String role) {
- CamelCatalog catalog = getCatalog();
+ CamelCatalog catalog = validationCatalog();
if (catalog == null) {
return List.of();
}
@@ -1216,7 +1231,7 @@ final class SourceEditAssist {
if (!context.startsWith("yaml:")) {
return List.of();
}
- CamelCatalog catalog = getCatalog();
+ CamelCatalog catalog = validationCatalog();
if (catalog == null) {
return List.of();
}
@@ -1552,6 +1567,146 @@ final class SourceEditAssist {
return SourceValidator.validateYamlEndpoints(content, validationCatalog());
}
+ /**
+ * The Camel checks of a Java or XML DSL route file (CAMEL-25208): the endpoint uris and simple expressions the
+ * compiler cannot see, as "Line N: message". The endpoints no route consumes are left to camel validate: the editor
+ * runs this while typing, and would read the whole project each time.
+ */
+ List<String> validateRoutes(Path file, String content) {
+ List<String> answer = new ArrayList<>();
+ for (RouteAssist.Diagnostic d : RouteAssist.diagnostics(file.getFileName().toString(), content,
+ validationCatalog(), null, javaSources(), false)) {
+ if (d.severity() == RouteAssist.Severity.ERROR) {
+ answer.add(d.format());
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The quick doc of each line of a Java or XML DSL route file (CAMEL-25208), read from its model: the component of
+ * an endpoint and the options it is given, the EIP of a step.
+ */
+ Map<Integer, List<SourceViewer.DocEntry>> provideRouteQuickDocs(Path file, List<JsonObject> codeData) {
+ CamelCatalog catalog = validationCatalog();
+ if (catalog == null || codeData.isEmpty()) {
+ return Map.of();
+ }
+ List<String> lines = new ArrayList<>(codeData.size());
+ for (JsonObject jo : codeData) {
+ lines.add(jo.getString("code") != null ? jo.getString("code") : "");
+ }
+ Map<Integer, List<SourceViewer.DocEntry>> result = new LinkedHashMap<>();
+ for (RouteNodes.Node n : routeNodes(file, lines)) {
+ int idx = n.line() - 1;
+ if (idx < 0 || idx >= codeData.size() || result.containsKey(idx)) {
+ continue;
+ }
+ if (n.kind() == RouteNodes.Kind.ENDPOINT) {
+ EipDocSupport.buildEndpointInlineDoc(result, codeData, catalog, n.uri(), idx);
+ } else if (n.kind() == RouteNodes.Kind.STEP && !hasEndpoint(file, lines, n.line())
+ && catalog.eipModel(n.eip()) != null) {
+ EipDocSupport.buildEipInlineDoc(result, codeData, catalog, n.eip(), null, idx);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * The quick doc of the line the cursor is on in a Java or XML DSL route file (CAMEL-25208): the component of an
+ * endpoint with the options it is given, else the EIP of the step and the language of its expression.
+ */
+ List<SourceViewer.DocEntry> provideRouteEditQuickDoc(Path file, List<String> lines, int cursorRow) {
+ CamelCatalog catalog = validationCatalog();
+ if (catalog == null || lines == null || cursorRow < 0 || cursorRow >= lines.size()) {
+ return List.of();
+ }
+ List<RouteNodes.Node> here = RouteNodes.at(routeNodes(file, lines), cursorRow + 1);
+ List<SourceViewer.DocEntry> answer = new ArrayList<>();
+ for (RouteNodes.Node n : here) {
+ if (n.kind() == RouteNodes.Kind.ENDPOINT && n.uri() != null) {
+ endpointDoc(catalog, n.uri(), answer);
+ return answer;
+ }
+ }
+ for (RouteNodes.Node n : here) {
+ if (n.kind() == RouteNodes.Kind.STEP && answer.isEmpty()) {
+ EipModel eip = catalog.eipModel(n.eip());
+ if (eip != null) {
+ answer.add(SourceViewer.DocEntry.of((eip.getTitle() != null ? eip.getTitle() : n.eip()) + " — "
+ + (eip.getDescription() != null ? eip.getDescription() : "")));
+ }
+ } else if (n.kind() == RouteNodes.Kind.EXPRESSION && n.language() != null) {
+ LanguageModel language = catalog.languageModel(n.language());
+ if (language != null) {
+ answer.add(SourceViewer.DocEntry.of((language.getTitle() != null ? language.getTitle() : n.language())
+ + (n.predicate() ? " predicate" : " expression") + ": "
+ + (n.text() != null ? n.text() : "")));
+ }
+ }
+ }
+ return answer;
+ }
+
+ /** The component of an endpoint, then each option it is given with its doc. */
+ private static void endpointDoc(CamelCatalog catalog, String uri, List<SourceViewer.DocEntry> answer) {
+ String component = uri.contains(":") ? uri.substring(0, uri.indexOf(':')) : uri;
+ ComponentModel model = catalog.componentModel(component);
+ if (model == null) {
+ return;
+ }
+ String title = model.getTitle() != null ? model.getTitle() : component;
+ answer.add(SourceViewer.DocEntry.of(title + " — " + (model.getDescription() != null ? model.getDescription() : "")));
+ Map<String, String> props;
+ try {
+ props = catalog.endpointProperties(uri);
+ } catch (Exception e) {
+ return;
+ }
+ int q = uri.indexOf('?');
+ String query = q >= 0 ? uri.substring(q + 1) : "";
+ for (ComponentModel.EndpointOptionModel opt : model.getEndpointOptions()) {
+ String value = props != null ? props.get(opt.getName()) : null;
+ // the options written in the uri's query, not the path parameters already in the title's line
+ if (value != null && (query.startsWith(opt.getName() + "=") || query.contains("&" + opt.getName() + "="))) {
+ String doc = EipDocSupport.formatOptionDoc(opt);
+ answer.add(SourceViewer.DocEntry.of(opt.getName() + "=" + value + (doc != null ? " — " + doc : "")));
+ }
+ }
+ }
+
+ private boolean hasEndpoint(Path file, List<String> lines, int line) {
+ for (RouteNodes.Node n : RouteNodes.at(routeNodes(file, lines), line)) {
+ if (n.kind() == RouteNodes.Kind.ENDPOINT) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** The nodes of a route file's content, read again only when the content changed. */
+ private List<RouteNodes.Node> routeNodes(Path file, List<String> lines) {
+ String content = String.join("\n", lines);
+ if (!content.equals(routeNodesContent) || !Objects.equals(file, routeNodesFile)) {
+ routeNodesCache = RouteAssist.nodes(file.getFileName().toString(), content, validationCatalog(), javaSources());
+ routeNodesContent = content;
+ routeNodesFile = file;
+ }
+ return routeNodesCache;
+ }
+
+ /** The Java sources of the project, for the constants a route refers to in another class; kept for a while. */
+ private Map<String, Supplier<String>> javaSources() {
+ long now = System.currentTimeMillis();
+ if (javaSourcesCache == null || now - javaSourcesCacheTime > JAVA_SOURCES_TTL_MS
+ || !Objects.equals(javaSourcesCacheDir, rootDir)) {
+ javaSourcesCache = RouteAssist.javaSources(rootDir);
+ javaSourcesCacheTime = now;
+ javaSourcesCacheDir = rootDir;
+ }
+ return javaSourcesCache;
+ }
+
List<String> validateYamlSimple(String content) {
return SourceValidator.validateYamlSimple(content, validationCatalog());
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index 22fab2b563c3..4c6cde703bac 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -822,6 +822,12 @@ class SourceTab extends AbstractTab {
* type (Camel YAML, other Camel source, properties, or none).
*/
private void configureEditAssist(Path filePath) {
+ // the Camel checks of a Java or XML DSL route file (CAMEL-25208); YAML has its own
+ boolean routeFile = !SourceEditAssist.isYamlFile(filePath)
+ && (isJavaRouteFile(filePath) || isCamelSourceFile(filePath));
+ sourceViewer.setRouteValidator(routeFile ? content -> assist.validateRoutes(filePath, content) : null);
+ String name = filePath.getFileName().toString();
+ sourceViewer.setUriCompletion(!routeFile ? null : name.endsWith(".java") ? "java" : "xml");
if (isCamelSourceFile(filePath)) {
sourceViewer.setQuickDocProvider(assist::provideCamelQuickDocs);
sourceViewer.setDeprecatedLineScanner(null);
@@ -833,10 +839,21 @@ class SourceTab extends AbstractTab {
sourceViewer.setListItemNodeChecker(assist::isListChildrenNode);
sourceViewer.setEditQuickDocProvider(assist::provideEditQuickDoc);
} else {
- sourceViewer.setAutocompleteProvider(null);
- sourceViewer.setAutocompleteValueProvider(null);
- sourceViewer.setEditQuickDocProvider(null);
+ // XML: the quick doc of the routes read into the model, Tab in the uri attributes (CAMEL-25208)
+ sourceViewer.setQuickDocProvider(cd -> assist.provideRouteQuickDocs(filePath, cd));
+ sourceViewer.setAutocompleteProvider(assist::provideYamlKeyCompletions);
+ sourceViewer.setAutocompleteValueProvider(assist::provideYamlValueCompletions);
+ sourceViewer.setEditQuickDocProvider(
+ (lines, row) -> assist.provideRouteEditQuickDoc(filePath, lines, row));
}
+ } else if (routeFile) {
+ // Java: the quick doc of the routes read into the model by the Java DSL parser (CAMEL-25208)
+ sourceViewer.setQuickDocProvider(cd -> assist.provideRouteQuickDocs(filePath, cd));
+ sourceViewer.setDeprecatedLineScanner(null);
+ // Tab in the endpoint uri of from, to, toD...: component names, options and values, as YAML uris have
+ sourceViewer.setAutocompleteProvider(assist::provideYamlKeyCompletions);
+ sourceViewer.setAutocompleteValueProvider(assist::provideYamlValueCompletions);
+ sourceViewer.setEditQuickDocProvider((lines, row) -> assist.provideRouteEditQuickDoc(filePath, lines, row));
} else if (SourceEditAssist.isPropertiesFile(filePath)) {
sourceViewer.setQuickDocProvider(assist::providePropertiesQuickDocs);
sourceViewer.setDeprecatedLineScanner(assist::scanDeprecatedProperties);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 1bdb50ecff74..356feb3c1d79 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -187,6 +187,12 @@ class SourceViewer {
private PropertiesValidator propertiesValidator;
private EndpointValidator endpointValidator;
private EndpointValidator simpleValidator;
+ private EndpointValidator routeValidator;
+ /** java or xml: Tab completes the endpoint uris of a Java or XML route file; null for neither. */
+ private String uriCompletion;
+ /** The completion being chosen in a uri: its row, the column its prefix ends at, the prefix and suffix. */
+ private UriCompletion pendingUriCompletion;
+ private List<String> routeProblems = List.of();
private List<String> validationErrors;
private int validationErrorScroll;
private Map<Integer, String> inlineErrors = Collections.emptyMap();
@@ -257,6 +263,29 @@ class SourceViewer {
this.simpleValidator = simpleValidator;
}
+ /**
+ * The Camel checks of a Java or XML DSL route file (CAMEL-25208): its problems are marked on their lines while
+ * editing. An XML file with problems is not saved, as a YAML file; a Java file is saved and the problems are said,
+ * as a Java file is the application's code, and what the checks cannot know must never keep it from being saved.
+ */
+ void setRouteValidator(EndpointValidator routeValidator) {
+ this.routeValidator = routeValidator;
+ }
+
+ /**
+ * Tab completion in the endpoint uris of a Java or XML route file (CAMEL-25208): in the string given to from, to,
+ * toD... of Java, or the uri attribute of those elements in XML, the component names, the endpoint options and
+ * their values, from the completion providers the YAML uris use.
+ *
+ * @param dsl java or xml; null for a file that is neither
+ */
+ void setUriCompletion(String dsl) {
+ this.uriCompletion = dsl;
+ }
+
+ private record UriCompletion(int row, int endCol, String prefix, String suffix) {
+ }
+
void hide() {
exitEditMode();
visible = false;
@@ -268,6 +297,8 @@ class SourceViewer {
propertiesValidator = null;
endpointValidator = null;
simpleValidator = null;
+ routeValidator = null;
+ uriCompletion = null;
}
void reset() {
@@ -307,6 +338,8 @@ class SourceViewer {
propertiesValidator = null;
endpointValidator = null;
simpleValidator = null;
+ routeValidator = null;
+ uriCompletion = null;
}
boolean isMarkdownMode() {
@@ -635,6 +668,11 @@ class SourceViewer {
return editState.text();
}
+ /** Package-private for tests: the problems marked on the lines of the edit buffer, by 0-based line. */
+ Map<Integer, String> inlineErrors() {
+ return inlineErrors;
+ }
+
private boolean handleEditKeyEvent(KeyEvent ke) {
if (validationErrors != null) {
if (ke.isCancel() || ke.isKey(KeyCode.ENTER)) {
@@ -968,6 +1006,10 @@ class SourceViewer {
&& editableFile.getFileName().toString().toLowerCase().endsWith(".properties");
}
+ private boolean isXmlFile() {
+ return editableFile != null && editableFile.getFileName().toString().toLowerCase().endsWith(".xml");
+ }
+
private boolean isCamelYamlFile() {
if (editableFile == null) {
return false;
@@ -977,13 +1019,118 @@ class SourceViewer {
}
private void openAutocomplete() {
+ pendingUriCompletion = null;
if (isCamelYamlFile()) {
openYamlAutocomplete();
+ } else if (uriCompletion != null) {
+ openUriAutocomplete();
} else {
openPropertiesAutocomplete();
}
}
+ /**
+ * The completion of the endpoint uri the cursor is in, in a Java or XML route: the component before the colon, an
+ * option after ? or &, its value after =. Read from the line alone, so it works while the file does not parse.
+ */
+ private void openUriAutocomplete() {
+ int row = editState.cursorRow();
+ int col = editState.cursorCol();
+ String line = editState.getLine(row);
+ EndpointUriContext c = "xml".equals(uriCompletion)
+ ? EndpointUriContext.inXml(line, col) : EndpointUriContext.inJava(line, col);
+ if (c == null || !c.isEndpoint() || autocompleteProvider == null) {
+ return;
+ }
+ String before = c.before();
+ int colon = before.indexOf(':');
+ List<AutocompletePopup.CompletionItem> items;
+ String prefix;
+ String suffix;
+ if (colon < 0) {
+ items = autocompleteProvider.provide("yaml-uri:" + c.role());
+ prefix = before;
+ suffix = ":";
+ } else {
+ String scheme = before.substring(0, colon);
+ int q = before.indexOf('?');
+ if (q < 0) {
+ // the path of the uri: what goes there is the component's own
+ return;
+ }
+ int sep = Math.max(before.lastIndexOf('?'), before.lastIndexOf('&'));
+ String segment = before.substring(sep + 1);
+ int eq = segment.indexOf('=');
+ if (eq >= 0) {
+ if (autocompleteValueProvider == null) {
+ return;
+ }
+ items = autocompleteValueProvider.provide("yaml:" + scheme + ":" + segment.substring(0, eq));
+ prefix = segment.substring(eq + 1);
+ suffix = "";
+ } else {
+ List<String> given = new ArrayList<>();
+ for (String pair : (sep > q ? before.substring(q + 1, sep) : "").split("&")) {
+ if (!pair.isEmpty()) {
+ given.add(pair.contains("=") ? pair.substring(0, pair.indexOf('=')) : pair);
+ }
+ }
+ items = autocompleteProvider.provide("yaml:" + scheme + ":" + c.role() + ":" + String.join(",", given)
+ + "|" + before.substring(0, sep));
+ prefix = segment;
+ suffix = "=";
+ }
+ }
+ if (items != null && !items.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(items, prefix, prefix, true);
+ if (colon < 0) {
+ autocompletePopup.setTitlePrefix("Components");
+ }
+ pendingUriCompletion = new UriCompletion(row, col, prefix, suffix);
+ }
+ }
+
+ /** Replaces the prefix the completion was opened on with the chosen item and what follows it (: or =). */
+ private void insertUriCompletion(AutocompletePopup.CompletionItem item) {
+ UriCompletion jc = pendingUriCompletion;
+ pendingUriCompletion = null;
+ if (editState.cursorRow() != jc.row()) {
+ return;
+ }
+ // the arrows move the cursor within the prefix while the popup is open: back to its end
+ int col = editState.cursorCol();
+ for (; col < jc.endCol(); col++) {
+ editState.moveCursorRight();
+ }
+ for (; col > jc.endCol(); col--) {
+ editState.moveCursorLeft();
+ }
+ for (int i = 0; i < jc.prefix().length(); i++) {
+ editState.deleteBackward();
+ }
+ // Tab in the middle of a word replaces all of it: the rest of the name after the cursor goes too
+ String line = editState.getLine(jc.row());
+ int from = editState.cursorCol();
+ int end = from;
+ while (end < line.length() && isUriWordChar(line.charAt(end), jc.suffix())) {
+ end++;
+ }
+ for (int i = from; i < end; i++) {
+ editState.deleteForward();
+ }
+ // the : or = the name is followed by is not doubled when it is there already
+ boolean hasSuffix = !jc.suffix().isEmpty() && line.startsWith(jc.suffix(), end);
+ editState.insert(item.key() + (hasSuffix ? "" : jc.suffix()));
+ }
+
+ /** A character of the name or value being completed: up to the : of a component, the = of an option, the & or ". */
+ private static boolean isUriWordChar(char c, String suffix) {
+ if (suffix.isEmpty()) {
+ return c != '&' && c != '"' && c != '\'' && c != '\\';
+ }
+ return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '.';
+ }
+
private void openPropertiesAutocomplete() {
String lineText = editState.getLine(editState.cursorRow());
int col = editState.cursorCol();
@@ -1149,6 +1296,10 @@ class SourceViewer {
private void insertCompletion(AutocompletePopup.CompletionItem item, boolean valueMode, boolean listItem) {
recordEditChange();
+ if (pendingUriCompletion != null) {
+ insertUriCompletion(item);
+ return;
+ }
String currentLine = editState.getLine(editState.cursorRow());
if (isCamelYamlFile()) {
insertYamlCompletion(item, valueMode, currentLine, listItem);
@@ -1274,7 +1425,7 @@ class SourceViewer {
dirty = false;
Path path = editableFile;
boolean restoreMarkdownMode = markdownModeBeforeEdit;
- notifySave("Saved: " + editableFile.getFileName(), false);
+ notifySaved(editableFile);
editMode = false;
editState.clear();
markdownModeBeforeEdit = false;
@@ -1301,13 +1452,14 @@ class SourceViewer {
dirty = false;
originalEditText = content;
lineStatuses = null;
- notifySave("Saved: " + editableFile.getFileName(), false);
+ notifySaved(editableFile);
} catch (IOException e) {
notifySave("Save failed: " + e.getMessage(), true);
}
}
private void validateAndNotify(String content) {
+ routeProblems = List.of();
if (validateOnSave && isCamelYamlFile()) {
List<String> msgs = new ArrayList<>();
msgs.addAll(SourceValidationSupport.formatSchemaErrors(validateYaml(content)));
@@ -1337,10 +1489,34 @@ class SourceViewer {
inlineErrors = buildInlineErrors(msgs, content);
return;
}
+ } else if (validateOnSave && routeValidator != null) {
+ List<String> msgs = routeValidator.validate(content);
+ msgs = msgs != null ? msgs : List.of();
+ inlineErrors = msgs.isEmpty() ? Collections.emptyMap() : buildInlineErrors(msgs, content);
+ if (isXmlFile() && !msgs.isEmpty()) {
+ // XML routes as YAML routes: the problems are shown and the file is not saved
+ validationErrors = msgs;
+ validationErrorScroll = 0;
+ } else {
+ // Java: marked, and said when saved, but not blocking the save
+ routeProblems = msgs;
+ }
+ return;
}
inlineErrors = Collections.emptyMap();
}
+ /** The notice of a save: the file, and the Camel problems of a Java or XML route file when it has some. */
+ private void notifySaved(Path file) {
+ if (routeProblems.isEmpty()) {
+ notifySave("Saved: " + file.getFileName(), false);
+ } else {
+ notifySave("Saved: " + file.getFileName() + " with " + routeProblems.size() + " Camel problem"
+ + (routeProblems.size() > 1 ? "s" : "") + ": " + routeProblems.get(0),
+ true);
+ }
+ }
+
private void jumpToNextError() {
List<Integer> errorLines = new ArrayList<>(inlineErrors.keySet());
Collections.sort(errorLines);
@@ -1389,6 +1565,11 @@ class SourceViewer {
}
} else if (isPropertiesFile() && propertiesValidator != null) {
msgs.addAll(validateProperties(content));
+ } else if (routeValidator != null) {
+ List<String> routeErrors = routeValidator.validate(content);
+ if (routeErrors != null) {
+ msgs.addAll(routeErrors);
+ }
}
inlineErrors = msgs.isEmpty() ? Collections.emptyMap() : buildInlineErrors(msgs, content);
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
index b9bdd0b7996a..4f71eee20eb8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
@@ -20,6 +20,8 @@ explorer on the left and a source viewer on the right.
- **Ctrl+R** — open refactoring menu in edit mode (YAML files only; choose an action for the current line)
- **Space** — cycle format (YAML/Java/XML) for Camel routes
- Quick documentation panel is shown at the bottom for Camel source files
+ (YAML, XML and Java DSL routes: the component and options of an endpoint,
+ the EIP of a step, the language of an expression)
- **/** — search in source
- **h** — highlight text
- **n/N** — next/previous match
@@ -38,6 +40,10 @@ explorer on the left and a source viewer on the right.
- Quick documentation panel is shown at the bottom (shows doc for current line)
- **F7** — show diff of unsaved changes
- **F9** — jump to next validation error
+- Java and XML DSL routes are checked as you type, like YAML routes: endpoint
+ options, simple expressions, and a `to` with `${...}` that should be a `toD`.
+ The problems are marked on their lines; an XML file with problems is not saved
+ (as YAML), a Java file is saved and the problems are said.
## Edit Mode (Tab Completion)
Press **F4** to enter edit mode, then **Tab** for context-aware completion:
@@ -66,8 +72,15 @@ Press **F4** to enter edit mode, then **Tab** for context-aware completion:
- Value completion shows enum choices, boolean values, and `{{placeholder}}`
suggestions from your `.properties` files
+**Java and XML DSL routes:**
+- In the endpoint uri of `from`, `to`, `toD`, `wireTap`, `enrich`, `pollEnrich`
+ and `poll` (the string given to them in Java, their `uri` attribute in XML),
+ Tab completes the component name before the `:`, the endpoint options after
+ `?` or `&` (`&` in XML; filtered by consumer/producer role, already given
+ ones left out), and the value of an option after `=`
+
Use **Up/Down** to navigate, **Enter** to accept, **Esc** to dismiss, and
-type to filter the completion list.
+type to filter the completion list (an exact or prefix match comes first).
## Route Jump Links
Lines with `to:`, `toD:`, `wireTap:`, or similar endpoints that reference
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointUriCompletionTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointUriCompletionTest.java
new file mode 100644
index 000000000000..cf28f1a2cb63
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointUriCompletionTest.java
@@ -0,0 +1,249 @@
+/*
+ * 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.tui;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tab completion in the endpoint uris of Java and XML route files (CAMEL-25208).
+ */
+class EndpointUriCompletionTest {
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ Theme.resetForTesting();
+ }
+
+ @Test
+ void theStringTheCursorIsIn() {
+ EndpointUriContext c = EndpointUriContext.inJava(" from(\"timer:tick?per\")", 28);
+ assertThat(c.call()).isEqualTo("from");
+ assertThat(c.before()).isEqualTo("timer:tick?per");
+ assertThat(c.isEndpoint()).isTrue();
+ assertThat(c.role()).isEqualTo("consumer");
+
+ c = EndpointUriContext.inJava(" .to( \"kaf", 21);
+ assertThat(c.call()).isEqualTo("to");
+ assertThat(c.before()).isEqualTo("kaf");
+ assertThat(c.role()).isEqualTo("producer");
+
+ // after the string, in another call, in a comment, not in a string
+ assertThat(EndpointUriContext.inJava(" from(\"timer:tick\").to(", 30)).isNull();
+ // the second string of a concatenation is not the argument's start
+ assertThat(EndpointUriContext.inJava(" log.info(\"x\" + \"y", 25)).isNull();
+ assertThat(EndpointUriContext.inJava(" log.info(\"x", 19).call()).isEqualTo("info");
+ assertThat(EndpointUriContext.inJava(" // to(\"kaf", 18)).isNull();
+ assertThat(EndpointUriContext.inJava(" x = \"kaf", 17)).isNull();
+ // an escaped quote stays in the string
+ assertThat(EndpointUriContext.inJava(" to(\"a\\\"b", 16).before()).isEqualTo("a\\\"b");
+ }
+
+ @Test
+ void theUriAttributeTheCursorIsIn() {
+ String line = " <from uri=\"timer:tick?per\"/>";
+ EndpointUriContext c = EndpointUriContext.inXml(line, line.length() - 3);
+ assertThat(c.call()).isEqualTo("from");
+ assertThat(c.before()).isEqualTo("timer:tick?per");
+ assertThat(c.role()).isEqualTo("consumer");
+
+ line = "<camel:to id=\"x\" uri='kafka:t?a=1&b";
+ c = EndpointUriContext.inXml(line, line.length());
+ assertThat(c.call()).isEqualTo("to");
+ assertThat(c.before()).isEqualTo("kafka:t?a=1&b");
+
+ // the attribute closed before the cursor, another attribute, no element
+ line = " <to uri=\"seda:a\" id=\"x";
+ assertThat(EndpointUriContext.inXml(line, line.length())).isNull();
+ line = " <to id=\"seda";
+ assertThat(EndpointUriContext.inXml(line, line.length())).isNull();
+ assertThat(EndpointUriContext.inXml("uri=\"seda", 9)).isNull();
+ }
+
+ @Test
+ void anXmlUri() throws Exception {
+ SourceViewer viewer = viewer("routes.xml", """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick?period=1000&fixedR"/>
+ <to uri="sed"/>
+ </route>
+ </routes>
+ """);
+ viewer.setUriCompletion("xml");
+ cursorAt(viewer, 3, "\"/>");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ type(viewer, "a");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 3)).isEqualTo(" <to uri=\"seda:\"/>");
+
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ for (int i = 0; i < 3; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 2)).isEqualTo(" <from uri=\"timer:tick?period=1000&fixedRate=\"/>");
+ }
+
+ private SourceViewer viewer(String src) throws Exception {
+ return viewer("MyRoute.java", src);
+ }
+
+ private SourceViewer viewer(String fileName, String src) throws Exception {
+ Path file = tempDir.resolve(fileName);
+ Files.writeString(file, src, StandardCharsets.UTF_8);
+ AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(List.of());
+ AtomicReference<List<InfraInfo>> infraData = new AtomicReference<>(List.of());
+ SourceEditAssist assist = new SourceEditAssist(new MonitorContext(data, infraData));
+ SourceViewer viewer = new SourceViewer();
+ viewer.setAutocompleteProvider(assist::provideYamlKeyCompletions);
+ viewer.setAutocompleteValueProvider(assist::provideYamlValueCompletions);
+ viewer.setUriCompletion("java");
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+ return viewer;
+ }
+
+ /** Puts the cursor on the line, before the given text at its end. */
+ private static void cursorAt(SourceViewer viewer, int row, String tail) {
+ for (int i = 0; i < row; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ for (int i = 0; i < tail.length(); i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ }
+ }
+
+ private static void type(SourceViewer viewer, String text) {
+ for (char ch : text.toCharArray()) {
+ viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+ }
+ }
+
+ private static String line(SourceViewer viewer, int row) {
+ return viewer.editText().split("\n")[row];
+ }
+
+ @Test
+ void theComponentOfAnEndpoint() throws Exception {
+ SourceViewer viewer = viewer("""
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .to("sed");
+ }
+ }
+ """);
+ cursorAt(viewer, 3, "\");");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ // the popup filters as the prefix is typed further, then Enter takes the selected component
+ type(viewer, "a");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 3)).isEqualTo(" .to(\"seda:\");");
+ }
+
+ @Test
+ void anOptionAndItsValue() throws Exception {
+ SourceViewer viewer = viewer("""
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .to("file:out?fileExi");
+ }
+ }
+ """);
+ cursorAt(viewer, 3, "\");");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 3)).isEqualTo(" .to(\"file:out?fileExist=\");");
+
+ // the cursor is after the = now: the values of the option
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ type(viewer, "Appe");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 3)).isEqualTo(" .to(\"file:out?fileExist=Append\");");
+ }
+
+ @Test
+ void aNameInTheMiddleOfTheUriIsReplacedWhole() throws Exception {
+ // found in the live TUI: Tab after siz in ?siz=100 wrote size==100
+ SourceViewer viewer = viewer("""
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .to("seda:orders?siz=100&blockWhenFull=true");
+ }
+ }
+ """);
+ cursorAt(viewer, 3, "=100&blockWhenFull=true\");");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 3)).isEqualTo(" .to(\"seda:orders?size=100&blockWhenFull=true\");");
+
+ // Tab with the cursor inside a name: the rest of it is replaced as well
+ viewer = viewer("""
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .to("sedx:orders");
+ }
+ }
+ """);
+ cursorAt(viewer, 3, "x:orders\");");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ type(viewer, "da");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertThat(line(viewer, 3)).isEqualTo(" .to(\"seda:orders\");");
+ }
+
+ @Test
+ void noCompletionOutsideAnEndpoint() throws Exception {
+ SourceViewer viewer = viewer("""
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .log("sed");
+ }
+ }
+ """);
+ String before = viewer.editText();
+ cursorAt(viewer, 3, "\");");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ // no popup: Enter splits the line as in any editor
+ assertThat(viewer.editText()).isNotEqualTo(before);
+ assertThat(viewer.editText()).doesNotContain("seda:");
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRouteValidationTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRouteValidationTest.java
new file mode 100644
index 000000000000..6d4b9dc1fcc7
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRouteValidationTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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.tui;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The Camel checks of Java and XML DSL route files in the Source tab (CAMEL-25208): marked on their lines, said on
+ * save, never blocking it.
+ */
+class SourceRouteValidationTest {
+
+ private static final String JAVA = """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class MyRoute extends RouteBuilder {
+ @Override
+ public void configure() throws Exception {
+ from("timer:tick?peroid=1000")
+ .to("seda:out");
+ }
+ }
+ """;
+
+ @TempDir
+ Path tempDir;
+
+ private final AtomicReference<String> lastNotification = new AtomicReference<>();
+ private final AtomicReference<Boolean> lastNotificationError = new AtomicReference<>();
+
+ private static SourceEditAssist assist() {
+ AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(List.of());
+ AtomicReference<List<InfraInfo>> infraData = new AtomicReference<>(List.of());
+ return new SourceEditAssist(new MonitorContext(data, infraData));
+ }
+
+ @BeforeEach
+ void setUp() {
+ Theme.resetForTesting();
+ }
+
+ @Test
+ void theEndpointsOfAJavaRouteAreChecked() {
+ List<String> errors = assist().validateRoutes(Path.of("MyRoute.java"), JAVA);
+ assertThat(errors).containsExactly("Line 6: timer: Unknown option 'peroid'. Did you mean: [period]");
+ assertThat(assist().validateRoutes(Path.of("MyRoute.java"), JAVA.replace("peroid", "period"))).isEmpty();
+ }
+
+ @Test
+ void theRoutesOfAnXmlFileAreChecked() {
+ List<String> errors = assist().validateRoutes(Path.of("routes.xml"), """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ <filter>
+ <simple>${header.foo} ==</simple>
+ <to uri="seda:out"/>
+ </filter>
+ </route>
+ </routes>
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).startsWith("Line 5: Simple syntax error");
+ }
+
+ @Test
+ void theQuickDocOfAJavaLine() {
+ String src = """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class MyRoute extends RouteBuilder {
+ @Override
+ public void configure() throws Exception {
+ from("timer:tick?period=1000")
+ .filter(simple("${header.foo} == 'bar'"))
+ .to("seda:out");
+ }
+ }
+ """;
+ List<String> lines = List.of(src.split("\n"));
+ SourceEditAssist assist = assist();
+ List<SourceViewer.DocEntry> from = assist.provideRouteEditQuickDoc(Path.of("MyRoute.java"), lines, 5);
+ assertThat(from).extracting(SourceViewer.DocEntry::text)
+ .satisfies(t -> assertThat(t.get(0)).startsWith("Timer — "))
+ .anySatisfy(t -> assertThat(t).startsWith("period=1000 — "));
+ List<SourceViewer.DocEntry> filter = assist.provideRouteEditQuickDoc(Path.of("MyRoute.java"), lines, 6);
+ assertThat(filter).extracting(SourceViewer.DocEntry::text)
+ .containsExactly(filter.get(0).text(), "Simple predicate: ${header.foo} == 'bar'");
+ assertThat(filter.get(0).text()).startsWith("Filter — ");
+ // a line of plain Java has none
+ assertThat(assist.provideRouteEditQuickDoc(Path.of("MyRoute.java"), lines, 2)).isEmpty();
+
+ List<JsonObject> codeData = new ArrayList<>();
+ for (String l : lines) {
+ JsonObject jo = new JsonObject();
+ jo.put("code", l);
+ codeData.add(jo);
+ }
+ Map<Integer, List<SourceViewer.DocEntry>> all = assist.provideRouteQuickDocs(Path.of("MyRoute.java"), codeData);
+ assertThat(all).containsKeys(5, 6, 7);
+ assertThat(all.get(7).get(0).text()).startsWith("SEDA — ");
+ }
+
+ @Test
+ void theQuickDocOfAnXmlLine() {
+ List<String> lines = List.of("""
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick?period=1000"/>
+ <to uri="seda:out"/>
+ </route>
+ </routes>
+ """.split("\n"));
+ List<SourceViewer.DocEntry> from = assist().provideRouteEditQuickDoc(Path.of("routes.xml"), lines, 2);
+ assertThat(from.get(0).text()).startsWith("Timer — ");
+ }
+
+ @Test
+ void anXmlFileWithAProblemIsNotSaved() throws Exception {
+ String xml = """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick?period=1000"/>
+ <to uri="seda:out"/>
+ </route>
+ </routes>
+ """;
+ Path file = tempDir.resolve("routes.xml");
+ Files.writeString(file, xml, StandardCharsets.UTF_8);
+ SourceViewer viewer = new SourceViewer();
+ SourceEditAssist assist = assist();
+ viewer.setRouteValidator(content -> assist.validateRoutes(file, content));
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+ // line 3: period=1000 becomes peroid=1000
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.HOME, KeyModifiers.NONE));
+ int col = viewer.editText().split("\n")[2].indexOf("period") + 3;
+ for (int i = 0; i < col; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DELETE, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofChar('i', KeyModifiers.NONE));
+ assertThat(viewer.editText()).contains("peroid=1000");
+
+ // as a YAML file: the problems are shown and the file is not saved
+ viewer.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.CTRL));
+ assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains("period=1000");
+ assertThat(viewer.isEditMode()).isTrue();
+ assertThat(viewer.inlineErrors()).containsOnlyKeys(2);
+ }
+
+ @Test
+ void aProblemIsMarkedAndSaidButTheFileIsSaved() throws Exception {
+ Path file = tempDir.resolve("MyRoute.java");
+ Files.writeString(file, JAVA.replace("peroid", "period"), StandardCharsets.UTF_8);
+ SourceViewer viewer = new SourceViewer();
+ viewer.setNotificationCallback((msg, error) -> {
+ lastNotification.set(msg);
+ lastNotificationError.set(error);
+ });
+ SourceEditAssist assist = assist();
+ viewer.setRouteValidator(content -> assist.validateRoutes(file, content));
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // line 6: timer:tick?period=1000 becomes timer:tick?peroid=1000
+ for (int i = 0; i < 5; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.HOME, KeyModifiers.NONE));
+ String line = viewer.editText().split("\n")[5];
+ int col = line.indexOf("period") + 3;
+ for (int i = 0; i < col; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, KeyModifiers.NONE));
+ }
+ // "per|iod": delete the i, type it after the o
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DELETE, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofChar('i', KeyModifiers.NONE));
+ assertThat(viewer.editText()).contains("timer:tick?peroid=1000");
+
+ // save and keep editing: saved, the problem said and marked on its line
+ viewer.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.CTRL));
+ assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains("peroid=1000");
+ assertThat(lastNotification.get()).startsWith("Saved: MyRoute.java with 1 Camel problem: Line 6: ");
+ assertThat(lastNotificationError.get()).isTrue();
+ assertThat(viewer.isEditMode()).isTrue();
+ assertThat(viewer.inlineErrors()).containsOnlyKeys(5);
+
+ // a clean save says nothing more than that: "peroi|d" back to "period"
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.BACKSPACE, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofChar('i', KeyModifiers.NONE));
+ assertThat(viewer.editText()).contains("timer:tick?period=1000");
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+ assertThat(lastNotification.get()).isEqualTo("Saved: MyRoute.java");
+ assertThat(lastNotificationError.get()).isFalse();
+ assertThat(Files.readString(file, StandardCharsets.UTF_8)).contains("period=1000");
+ }
+}