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 d30e5bdf7b1d chore: camel-jbang - source editor: problems marked when
a file opens, placeholder values and bean jumps, usages of an endpoint (#27200)
d30e5bdf7b1d is described below
commit d30e5bdf7b1db92c07819a897c494687ed68556c
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 13:08:25 2026 +0200
chore: camel-jbang - source editor: problems marked when a file opens,
placeholder values and bean jumps, usages of an endpoint (#27200)
The source editor marks the problems of a file as soon as it opens, shows
the values of property placeholders and where a bean is declared (with a jump
to it), and lists the usages of an endpoint (u).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 9 +
.../jbang/core/commands/tui/GotoRoutePopup.java | 16 +-
.../dsl/jbang/core/commands/tui/ProjectBeans.java | 238 +++++++++++++++++++++
.../jbang/core/commands/tui/SourceEditAssist.java | 43 ++++
.../dsl/jbang/core/commands/tui/SourceTab.java | 139 +++++++++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 84 +++++++-
.../src/main/resources/tui/help/source.md | 14 +-
.../commands/tui/SourceEditorProjectInfoTest.java | 190 ++++++++++++++++
8 files changed, 715 insertions(+), 18 deletions(-)
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 a7970e73ec76..cfe0839128e2 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -229,6 +229,13 @@ Camel routes:
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.
+What the project knows about the line comes first: the values of its property
placeholders (`{{orders.period}} = 500
+(application.properties)`, or the default that is used when it is not set),
and where the bean it refers to is declared.
+
+A line that refers to a bean (`bean:name`, `.bean(MyBean.class)`, `ref: name`,
`#class:com.foo.MyBean`...) shows a
+`↵ name` jump link when the project declares the bean (`@BindToRegistry`,
`@Named`, `@Component`, `@Bean`, or the beans
+of a YAML or XML file); *Enter* goes to its declaration, as it goes to the
route a `to` sends to. Press *u* on a line
+with an endpoint to see its usages: the routes that consume from it and the
steps that send to it, across the project.
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.
@@ -253,6 +260,8 @@ file is not saved and errors are displayed in a popup. This
catches mistakes ear
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).
+The problems of a file are marked as soon as it opens, in the view too: a red
`✗` on the line, the count in the title,
+and the problem of the selected line in the panel at the bottom.
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.
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
index 5f140b8eca5f..2dd9fce760a6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
@@ -46,6 +46,7 @@ class GotoRoutePopup {
private List<RouteItem> allEntries;
private List<RouteItem> filteredEntries;
private RouteItem selectedEntry;
+ private String name = "Go to Route";
record RouteItem(String routeId, String fromUri, String filePath, int
fromLine) {
}
@@ -55,10 +56,17 @@ class GotoRoutePopup {
}
void open(List<SourceTab.RouteEntry> routeIndex) {
- allEntries = new ArrayList<>();
+ List<RouteItem> items = new ArrayList<>();
for (SourceTab.RouteEntry re : routeIndex) {
- allEntries.add(new RouteItem(re.routeId(), re.fromUri(),
re.filePath(), re.fromLine()));
+ items.add(new RouteItem(re.routeId(), re.fromUri(), re.filePath(),
re.fromLine()));
}
+ openItems(items, "Go to Route");
+ }
+
+ /** Opens the popup with the given places, such as the usages of an
endpoint, under the given name. */
+ void openItems(List<RouteItem> items, String name) {
+ this.name = name;
+ allEntries = new ArrayList<>(items);
visible = true;
filter.clearFilter();
rebuildList();
@@ -206,8 +214,8 @@ class GotoRoutePopup {
int total = allEntries != null ? allEntries.size() : 0;
int shown = filteredEntries.size();
String title = shown == total
- ? " Go to Route (" + total + ") "
- : " Go to Route (" + shown + "/" + total + ") ";
+ ? " " + name + " (" + total + ") "
+ : " " + name + " (" + shown + "/" + total + ") ";
ListWidget list = ListWidget.builder()
.items(items.toArray(ListItem[]::new))
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectBeans.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectBeans.java
new file mode 100644
index 000000000000..676d00790fff
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectBeans.java
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The beans of a project for the Source tab: where each bean name and Java
class is declared, so a line of a route that
+ * refers to a bean (bean:name, .bean(MyBean.class), ref: name,
#class:com.foo.MyBean...) can jump to it, and the quick
+ * doc can say where it is. Bean names come from {@code @BindToRegistry},
{@code @Named}, {@code @Component},
+ * {@code @Service} and {@code @Bean} in Java, and from the beans of YAML and
XML files.
+ */
+final class ProjectBeans {
+
+ /**
+ * Where a bean or class is declared.
+ *
+ * @param label the bean name, or the simple name of the class
+ * @param type the class of the bean, when known
+ * @param filePath the file it is declared in
+ * @param line the line of the declaration, from 0
+ */
+ record Location(String label, String type, String filePath, int line) {
+ }
+
+ static final ProjectBeans NONE = new ProjectBeans(Map.of(), Map.of());
+
+ private static final Pattern PACKAGE =
Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;");
+ private static final Pattern CLASS = Pattern.compile(
+
"^\\s*(?:(?:public|protected|private|final|abstract|static|sealed)\\s+)*(?:class|record|enum|interface)\\s+(\\w+)");
+ private static final Pattern NAMED = Pattern.compile(
+
"@(?:[\\w.]+\\.)?(BindToRegistry|Named|Component|Service|Bean)\\b(?:\\s*\\(\\s*(?:(?:value|name)\\s*=\\s*)?\"([^\"]+)\")?");
+ private static final Pattern XML_BEAN =
Pattern.compile("<bean\\s[^>]*?name=\"([^\"]+)\"(?:[^>]*?type=\"([^\"]+)\")?");
+ private static final Pattern YAML_NAME =
Pattern.compile("^\\s*-?\\s*name:\\s*[\"']?([^\"'\\s#]+)");
+ private static final Pattern YAML_TYPE =
Pattern.compile("^\\s*type:\\s*[\"']?(?:#class:|#type:)?([\\w.$]+)");
+
+ // what a line of a route refers to
+ private static final Pattern REF_CLASS =
Pattern.compile("#(?:class|type):([\\w.$]+)");
+ private static final Pattern REF_BEAN_URI =
Pattern.compile("(?<![\\w.$])bean:([A-Za-z_][\\w-]*)");
+ private static final Pattern REF_JAVA_CLASS = Pattern.compile(
+
"\\.(?:bean|process)\\(\\s*(?:new\\s+)?(\\w+)\\s*(?:\\.class|\\()");
+ private static final Pattern REF_JAVA_NAME =
Pattern.compile("\\.(?:bean|process)\\(\\s*\"([^\"]+)\"");
+ private static final Pattern REF_YAML
+ =
Pattern.compile("^\\s*-?\\s*(ref|beanType|aggregationStrategy):\\s*[\"']?([\\w.$-]+)");
+ private static final Pattern REF_XML =
Pattern.compile("\\s(ref|beanType)=\"([\\w.$-]+)\"");
+
+ private final Map<String, Location> byName;
+ private final Map<String, Location> byClass;
+
+ private ProjectBeans(Map<String, Location> byName, Map<String, Location>
byClass) {
+ this.byName = byName;
+ this.byClass = byClass;
+ }
+
+ /** Reads the beans and classes the files declare. */
+ static ProjectBeans scan(List<Path> files) {
+ Map<String, Location> byName = new HashMap<>();
+ Map<String, Location> byClass = new HashMap<>();
+ for (Path file : files) {
+ String name =
file.getFileName().toString().toLowerCase(Locale.ROOT);
+ List<String> lines;
+ try {
+ lines = Files.readAllLines(file, StandardCharsets.UTF_8);
+ } catch (IOException | RuntimeException e) {
+ continue;
+ }
+ if (name.endsWith(".java")) {
+ scanJava(file.toString(), lines, byName, byClass);
+ } else if (name.endsWith(".yaml") || name.endsWith(".yml")) {
+ scanYaml(file.toString(), lines, byName);
+ } else if (name.endsWith(".xml")) {
+ scanXml(file.toString(), lines, byName);
+ }
+ }
+ return new ProjectBeans(byName, byClass);
+ }
+
+ private static void scanJava(
+ String filePath, List<String> lines, Map<String, Location> byName,
Map<String, Location> byClass) {
+ String pkg = "";
+ String className = null;
+ int classLine = -1;
+ for (int i = 0; i < lines.size(); i++) {
+ Matcher m = PACKAGE.matcher(lines.get(i));
+ if (m.find()) {
+ pkg = m.group(1) + ".";
+ }
+ m = CLASS.matcher(lines.get(i));
+ if (m.find()) {
+ className = m.group(1);
+ classLine = i;
+ break;
+ }
+ }
+ if (className == null) {
+ return;
+ }
+ String fqcn = pkg + className;
+ Location cls = new Location(className, fqcn, filePath, classLine);
+ byClass.putIfAbsent(className, cls);
+ byClass.putIfAbsent(fqcn, cls);
+ for (int i = 0; i < lines.size(); i++) {
+ Matcher m = NAMED.matcher(lines.get(i));
+ while (m.find()) {
+ boolean onClass = i < classLine;
+ String bean = m.group(2);
+ if (bean == null && onClass && !"Bean".equals(m.group(1))) {
+ // @BindToRegistry, @Named, @Component on the class: the
class name, starting in lower case
+ bean = Character.toLowerCase(className.charAt(0)) +
className.substring(1);
+ }
+ if (bean != null) {
+ byName.putIfAbsent(bean, new Location(bean, onClass ? fqcn
: null, filePath, i));
+ }
+ }
+ }
+ }
+
+ private static void scanYaml(String filePath, List<String> lines,
Map<String, Location> byName) {
+ int beansIndent = -1;
+ String pending = null;
+ int pendingLine = -1;
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+ continue;
+ }
+ int indent = line.length() - line.stripLeading().length();
+ if (beansIndent >= 0 && indent <= beansIndent) {
+ beansIndent = -1;
+ }
+ if (trimmed.equals("- beans:") || trimmed.equals("beans:")) {
+ beansIndent = indent;
+ continue;
+ }
+ if (beansIndent < 0) {
+ continue;
+ }
+ Matcher m = YAML_NAME.matcher(line);
+ if (m.find()) {
+ pending = m.group(1);
+ pendingLine = i;
+ byName.putIfAbsent(pending, new Location(pending, null,
filePath, i));
+ continue;
+ }
+ m = YAML_TYPE.matcher(line);
+ if (m.find() && pending != null) {
+ byName.put(pending, new Location(pending, m.group(1),
filePath, pendingLine));
+ pending = null;
+ }
+ }
+ }
+
+ private static void scanXml(String filePath, List<String> lines,
Map<String, Location> byName) {
+ for (int i = 0; i < lines.size(); i++) {
+ Matcher m = XML_BEAN.matcher(lines.get(i));
+ while (m.find()) {
+ String type = m.group(2) != null ?
m.group(2).replace("#class:", "") : null;
+ byName.putIfAbsent(m.group(1), new Location(m.group(1), type,
filePath, i));
+ }
+ }
+ }
+
+ /** The bean or class a line of a route refers to and the project
declares, or null. */
+ Location refOn(String line) {
+ if (line == null || (byName.isEmpty() && byClass.isEmpty())) {
+ return null;
+ }
+ Matcher m = REF_CLASS.matcher(line);
+ if (m.find()) {
+ return byClass(m.group(1));
+ }
+ m = REF_JAVA_CLASS.matcher(line);
+ if (m.find()) {
+ return byClass(m.group(1));
+ }
+ m = REF_JAVA_NAME.matcher(line);
+ if (m.find()) {
+ return byName(m.group(1));
+ }
+ m = REF_BEAN_URI.matcher(line);
+ if (m.find()) {
+ return byName(m.group(1));
+ }
+ m = REF_YAML.matcher(line);
+ if (!m.find()) {
+ m = REF_XML.matcher(line);
+ if (!m.find()) {
+ return null;
+ }
+ }
+ return "beanType".equals(m.group(1)) ? byClass(m.group(2)) :
byName(m.group(2));
+ }
+
+ private Location byName(String name) {
+ Location l = byName.get(name);
+ // a bean named after its class, as a bean without a name is
+ return l != null ? l :
byClass.get(Character.toUpperCase(name.charAt(0)) + name.substring(1));
+ }
+
+ private Location byClass(String name) {
+ Location l = byClass.get(name);
+ if (l == null && name.contains(".")) {
+ l = byClass.get(name.substring(name.lastIndexOf('.') + 1));
+ }
+ return l;
+ }
+
+ /** The quick doc of a bean: what it is and where, and that Enter goes
there. */
+ static String describe(Location l) {
+ String file = Path.of(l.filePath()).getFileName().toString();
+ String type = l.type() != null && !l.type().equals(l.label()) ? " (" +
l.type() + ")" : "";
+ return l.label() + type + " is declared in " + file + ":" + (l.line()
+ 1) + " — Enter goes there";
+ }
+}
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 5043cc166a5f..682d4cb50998 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
@@ -1471,6 +1471,49 @@ final class SourceEditAssist {
return items;
}
+ private static final Pattern PLACEHOLDER
+ = Pattern.compile("\\{\\{([^}:]+?)(?::([^}]*))?\\}\\}");
+
+ /**
+ * The values of the property placeholders on a line, from the .properties
files of the project: {{key}} = value
+ * (application.properties), or that it is not set and the default it
falls back to. {{env:NAME}} and {{sys:NAME}}
+ * come from the environment and the JVM, so they are said as such.
+ */
+ List<SourceViewer.DocEntry> placeholderDocs(String line) {
+ if (line == null || !line.contains("{{")) {
+ return List.of();
+ }
+ List<SourceViewer.DocEntry> answer = new ArrayList<>();
+ Matcher m = PLACEHOLDER.matcher(line);
+ while (m.find() && answer.size() < 3) {
+ String key = m.group(1).trim();
+ String def = m.group(2);
+ String text;
+ if (key.equals("env") || key.equals("sys")) {
+ // {{env:HOME}}: the key is the function, the name is what
follows the colon
+ text = "{{" + key + ":" + def + "}} is read from the " +
(key.equals("env") ? "environment" : "JVM")
+ + " when the route starts";
+ } else {
+ AutocompletePopup.CompletionItem item = null;
+ for (AutocompletePopup.CompletionItem ph :
loadPropertyPlaceholders()) {
+ if (ph.key().equals("{{" + key + "}}")) {
+ item = ph;
+ break;
+ }
+ }
+ if (item != null) {
+ text = "{{" + key + "}} = " + item.description() + " (" +
item.group() + ")";
+ } else if (def != null) {
+ text = "{{" + key + "}} is not set in the project's
properties: the default " + def + " is used";
+ } else {
+ text = "{{" + key + "}} is not set in the project's
.properties files";
+ }
+ }
+ answer.add(new SourceViewer.DocEntry(text, false, "Placeholder"));
+ }
+ return answer;
+ }
+
Map<Integer, List<SourceViewer.DocEntry>>
providePropertiesQuickDocs(List<JsonObject> codeData) {
CamelCatalog catalog = getCatalog();
if (catalog == null || codeData.isEmpty()) {
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 d4f795d88821..471d52575891 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
@@ -134,6 +134,9 @@ class SourceTab extends AbstractTab {
}
private List<RouteEntry> routeIndex = Collections.emptyList();
+ private ProjectBeans projectBeans = ProjectBeans.NONE;
+ private long projectBeansStamp;
+ private long projectBeansChecked;
private List<ToEntry> toIndex = Collections.emptyList();
final GotoRoutePopup gotoRoutePopup = new GotoRoutePopup();
final GotoSourceNodePopup gotoSourceNodePopup = new GotoSourceNodePopup();
@@ -273,6 +276,11 @@ class SourceTab extends AbstractTab {
return true;
}
+ if (focusOnViewer && sourceViewer.isVisible() &&
!sourceViewer.isEditMode() && ke.isChar('u')) {
+ openUsages();
+ return true;
+ }
+
if (!focusOnViewer) {
return handleFileListKey(ke);
}
@@ -442,6 +450,9 @@ class SourceTab extends AbstractTab {
TuiHelper.hint(spans, "g", "go to route");
}
TuiHelper.hint(spans, "Ctrl+G", "go to");
+ if (endpointOnSelectedLine() != null) {
+ TuiHelper.hint(spans, "u", "usages");
+ }
}
}
} else {
@@ -842,14 +853,14 @@ class SourceTab extends AbstractTab {
sourceViewer.setEndpointValidator(assist::validateYamlEndpoints);
sourceViewer.setSimpleValidator(assist::validateYamlSimple);
sourceViewer.setListItemNodeChecker(assist::isListChildrenNode);
-
sourceViewer.setEditQuickDocProvider(assist::provideEditQuickDoc);
+
sourceViewer.setEditQuickDocProvider(withProjectDocs(assist::provideEditQuickDoc));
} else {
// 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));
+ withProjectDocs((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)
@@ -858,14 +869,15 @@ class SourceTab extends AbstractTab {
// 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));
+ sourceViewer.setEditQuickDocProvider(
+ withProjectDocs((lines, row) ->
assist.provideRouteEditQuickDoc(filePath, lines, row)));
} else if (SourceEditAssist.isPropertiesFile(filePath)) {
sourceViewer.setQuickDocProvider(assist::providePropertiesQuickDocs);
sourceViewer.setDeprecatedLineScanner(assist::scanDeprecatedProperties);
sourceViewer.setAutocompleteProvider(assist::providePropertyCompletions);
sourceViewer.setAutocompleteValueProvider(assist::providePropertyValueCompletions);
sourceViewer.setPropertiesValidator(assist::validatePropertyLine);
-
sourceViewer.setEditQuickDocProvider(assist::provideEditPropertyQuickDoc);
+
sourceViewer.setEditQuickDocProvider(withProjectDocs(assist::provideEditPropertyQuickDoc));
} else {
sourceViewer.setQuickDocProvider(null);
sourceViewer.setDeprecatedLineScanner(null);
@@ -1354,7 +1366,71 @@ class SourceTab extends AbstractTab {
index.add(new RouteEntry(routeId, baseUri, filePath, fromLine));
}
+ /** The jump links of a file: to the routes its steps send to or are
called from, and to the beans it refers to. */
Map<Integer, SourceViewer.JumpLink> computeJumpLinks(Path currentFile) {
+ Map<Integer, SourceViewer.JumpLink> result = new
LinkedHashMap<>(computeRouteJumpLinks(currentFile));
+ ProjectBeans beans = projectBeans();
+ List<String> lines;
+ try {
+ lines = Files.readAllLines(currentFile, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return result;
+ }
+ for (int i = 0; i < lines.size(); i++) {
+ ProjectBeans.Location bean = beans.refOn(lines.get(i));
+ if (bean != null &&
!(bean.filePath().equals(currentFile.toString()) && bean.line() == i)) {
+ result.putIfAbsent(i, new SourceViewer.JumpLink(bean.label(),
bean.filePath(), bean.line()));
+ }
+ }
+ return result;
+ }
+
+ /** The beans and classes the project declares, read again when a file of
the project changed. */
+ ProjectBeans projectBeans() {
+ long now = System.currentTimeMillis();
+ if (now - projectBeansChecked < 2000) {
+ // the quick doc asks on every frame: look at the files at most
every 2 seconds
+ return projectBeans;
+ }
+ projectBeansChecked = now;
+ List<Path> files = routeSources();
+ long stamp = files.size();
+ for (Path f : files) {
+ stamp = 31 * stamp + modified(f) + f.hashCode();
+ }
+ if (stamp != projectBeansStamp) {
+ projectBeans = ProjectBeans.scan(files);
+ projectBeansStamp = stamp;
+ }
+ return projectBeans;
+ }
+
+ /**
+ * The quick doc of a line with what the project knows about it put first:
the values of its property placeholders
+ * and where the bean it refers to is declared.
+ */
+ private SourceViewer.EditQuickDocProvider
withProjectDocs(SourceViewer.EditQuickDocProvider provider) {
+ return (lines, row) -> {
+ List<SourceViewer.DocEntry> base = provider.provideForLine(lines,
row);
+ if (row < 0 || row >= lines.size()) {
+ return base;
+ }
+ List<SourceViewer.DocEntry> extra = new
ArrayList<>(assist.placeholderDocs(lines.get(row)));
+ ProjectBeans.Location bean = projectBeans().refOn(lines.get(row));
+ if (bean != null) {
+ extra.add(new
SourceViewer.DocEntry(ProjectBeans.describe(bean), false, "Bean"));
+ }
+ if (extra.isEmpty()) {
+ return base;
+ }
+ if (base != null) {
+ extra.addAll(base);
+ }
+ return extra;
+ };
+ }
+
+ private Map<Integer, SourceViewer.JumpLink> computeRouteJumpLinks(Path
currentFile) {
if (routeIndex.isEmpty()) {
return Collections.emptyMap();
}
@@ -1480,6 +1556,61 @@ class SourceTab extends AbstractTab {
}
}
+ /**
+ * The endpoint a route consumes from or sends to on the line selected in
the viewer, as the route index has it
+ * (direct:billing, seda:orders); null when the line has none.
+ */
+ String endpointOnSelectedLine() {
+ String file = sourceViewer.getCurrentFilePath();
+ int line = sourceViewer.getSelectedLine();
+ if (file == null) {
+ return null;
+ }
+ for (RouteEntry re : routeIndex) {
+ if (file.equals(re.filePath()) && re.fromLine() == line) {
+ return re.fromUri();
+ }
+ }
+ for (ToEntry te : toIndex) {
+ if (file.equals(te.filePath()) && te.toLine() == line) {
+ return te.toUri();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Lists where the endpoint of the selected line is used: the routes that
consume from it and those that send to it.
+ */
+ private void openUsages() {
+ buildRouteIndex();
+ String uri = endpointOnSelectedLine();
+ if (uri == null) {
+ if (ctx.notificationCallback != null) {
+ ctx.notificationCallback.accept("No endpoint of a route on
this line", false);
+ }
+ return;
+ }
+ gotoRoutePopup.openItems(usagesOf(uri), "Usages of " + uri);
+ }
+
+ /** The routes that consume from the endpoint and the steps that send to
it. */
+ List<GotoRoutePopup.RouteItem> usagesOf(String uri) {
+ List<GotoRoutePopup.RouteItem> items = new ArrayList<>();
+ for (RouteEntry re : routeIndex) {
+ if (uri.equals(re.fromUri())) {
+ items.add(new GotoRoutePopup.RouteItem(re.routeId(), "from " +
uri, re.filePath(), re.fromLine()));
+ }
+ }
+ for (ToEntry te : toIndex) {
+ if (uri.equals(te.toUri())) {
+ String routeId = te.routeId().isEmpty() ? "route" :
te.routeId();
+ items.add(new GotoRoutePopup.RouteItem(routeId, "to " + uri,
te.filePath(), te.toLine()));
+ }
+ }
+ return items;
+ }
+
private void openFileAt(String targetFilePath, int targetLine) {
String currentFile = sourceViewer.getCurrentFilePath();
if (currentFile != null && currentFile.equals(targetFilePath)) {
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 c9e29f72b88a..bea53fe8a16e 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
@@ -216,6 +216,10 @@ class SourceViewer {
private List<String> validationErrors;
private int validationErrorScroll;
private Map<Integer, String> inlineErrors = Collections.emptyMap();
+ /**
+ * The problems of the file shown, found when it was loaded: marked in the
view, and the editor starts with them.
+ */
+ private Map<Integer, String> viewErrors = Collections.emptyMap();
private boolean editInitialScroll;
private long lastBackgroundValidationTime;
private String lastBackgroundValidationContent;
@@ -643,6 +647,10 @@ class SourceViewer {
enterEditMode();
return true;
}
+ if (ke.isKey(KeyCode.F9) && !ke.hasShift() && !viewErrors.isEmpty()) {
+ goToNextProblem(viewErrors, selectedLine);
+ return true;
+ }
if (isMarkdownFile && ke.isChar(' ')) {
markdownMode = !markdownMode;
return true;
@@ -1037,6 +1045,9 @@ class SourceViewer {
originalEditText = editState.text();
lineStatuses = null;
diffOverlay = false;
+ // the problems found when the file was loaded are marked at once,
before the first change
+ inlineErrors = viewErrors;
+ lastBackgroundValidationContent = originalEditText;
editMode = true;
editHistory.seedInitial(editState);
refreshEditFindMatches();
@@ -1633,9 +1644,13 @@ class SourceViewer {
}
private void jumpToNextError() {
- List<Integer> errorLines = new ArrayList<>(inlineErrors.keySet());
+ goToNextProblem(inlineErrors, editState.cursorRow());
+ }
+
+ /** Goes to the first problem after the row, or wraps around to the first
one. */
+ private void goToNextProblem(Map<Integer, String> problems, int cursorRow)
{
+ List<Integer> errorLines = new ArrayList<>(problems.keySet());
Collections.sort(errorLines);
- int cursorRow = editState.cursorRow();
// find the first error line after the cursor
for (int line : errorLines) {
if (line > cursorRow) {
@@ -1664,6 +1679,12 @@ class SourceViewer {
lastBackgroundValidationTime = now;
lastBackgroundValidationContent = content;
+ List<String> msgs = validateContent(content);
+ inlineErrors = msgs.isEmpty() ? Collections.emptyMap() :
buildInlineErrors(msgs, content);
+ }
+
+ /** The problems the checks of the file type (Camel YAML, properties, Java
or XML routes) find in the content. */
+ private List<String> validateContent(String content) {
List<String> msgs = new ArrayList<>();
if (isCamelYamlFile()) {
if (endpointValidator != null) {
@@ -1686,7 +1707,25 @@ class SourceViewer {
msgs.addAll(routeErrors);
}
}
- inlineErrors = msgs.isEmpty() ? Collections.emptyMap() :
buildInlineErrors(msgs, content);
+ return msgs;
+ }
+
+ /** The problems of the file just loaded, by line; none when the checks
fail or do not apply. */
+ private Map<Integer, String> problemsOnLoad(String content) {
+ if (isMarkdownFile) {
+ return Collections.emptyMap();
+ }
+ try {
+ List<String> msgs = validateContent(content);
+ return msgs.isEmpty() ? Collections.emptyMap() :
buildInlineErrors(msgs, content);
+ } catch (RuntimeException e) {
+ return Collections.emptyMap();
+ }
+ }
+
+ /** Package-private for tests: the problems marked in the view, by line
from 0. */
+ Map<Integer, String> viewErrors() {
+ return viewErrors;
}
/**
@@ -2009,7 +2048,17 @@ class SourceViewer {
if (viewDocEntries != null && !viewDocEntries.isEmpty()) {
titleText = viewDocEntries.get(0).title();
}
- if (titleText != null) {
+ String problem = viewErrors.get(selectedLine);
+ if (problem != null) {
+ // the problem of the line goes before its documentation, as
the Error panel of the editor shows it
+ int remaining = Math.max(0, viewDocArea.width() - " Error
".length() - 3);
+ docLines.add(Line.from(
+ Span.styled("───", Theme.error()),
+ Span.styled(" Error ", Theme.error().bold()),
+ Span.styled("─".repeat(remaining), Theme.error())));
+ docLines.add(Line.from(Span.styled(problem, Theme.error())));
+ docLines.add(Line.from(Span.styled("F4 edit F9 next
problem", Style.EMPTY.dim())));
+ } else if (titleText != null) {
String prefix = "─── ";
String suffix = " ";
int remaining = Math.max(0, viewDocArea.width() -
prefix.length() - titleText.length() - suffix.length());
@@ -2020,7 +2069,7 @@ class SourceViewer {
} else {
docLines.add(Line.from(Span.styled("─".repeat(Math.max(1,
viewDocArea.width())), Style.EMPTY.dim())));
}
- if (viewDocEntries != null && !viewDocEntries.isEmpty()) {
+ if (problem == null && viewDocEntries != null &&
!viewDocEntries.isEmpty()) {
for (int d = 0; d < viewDocEntries.size() && d <
viewDocArea.height() - 1; d++) {
DocEntry entry = viewDocEntries.get(d);
Style docStyle = entry.deprecated() ?
Style.EMPTY.dim().italic() : Style.EMPTY.dim();
@@ -2445,6 +2494,9 @@ class SourceViewer {
if (isEditable()) {
TuiHelper.hint(spans, "F4", "edit");
}
+ if (!viewErrors.isEmpty()) {
+ TuiHelper.hint(spans, "F9", "next problem");
+ }
if (isMarkdownFile || currentRouteId != null) {
TuiHelper.hint(spans, "Space", "format");
}
@@ -2650,6 +2702,7 @@ class SourceViewer {
}
editableFile = Files.isWritable(filePath) ? filePath : null;
scanDeprecatedLines();
+ viewErrors = problemsOnLoad(String.join("\n", rawLines));
jumpLinks = Collections.emptyMap();
if (onFileLoaded != null) {
onFileLoaded.accept(filePath);
@@ -2663,6 +2716,7 @@ class SourceViewer {
markdownMode = false;
rawMarkdownContent = null;
editableFile = null;
+ viewErrors = Collections.emptyMap();
}
}
@@ -2676,6 +2730,7 @@ class SourceViewer {
void loadSource(MonitorContext ctx, String routeId, int targetLine, String
sourceLocationHint) {
// Process-sourced views are never editable (may be remote / not a
local file)
editableFile = null;
+ viewErrors = Collections.emptyMap();
editMode = false;
editState.clear();
@@ -2890,6 +2945,11 @@ class SourceViewer {
return Title.from(Line.from(spans));
}
if (currentRouteId == null) {
+ if (!viewErrors.isEmpty()) {
+ return Title.from(Line.from(
+ Span.styled(" Source [" + info + "] ", ts),
+ Span.styled(" errors: " + viewErrors.size() + " ",
Theme.error())));
+ }
return Title.from(Span.styled(" Source [" + info + "] ", ts));
}
@@ -3088,16 +3148,22 @@ class SourceViewer {
} else if (isSelected) {
spans.add(Span.styled(">> ", focused ? Theme.label().bold() :
Theme.label().dim()));
if (!prefix.isEmpty()) {
- spans.add(Span.styled(prefix, (focused ? Theme.label().bold()
: Theme.label().dim()).patch(selBg)));
+ Style numberStyle = viewErrors.containsKey(lineIndex)
+ ? Theme.error().bold() : focused ?
Theme.label().bold() : Theme.label().dim();
+ spans.add(Span.styled(prefix, numberStyle.patch(selBg)));
}
addLiveColumn(spans, lineIndex, selBg);
for (Span s : highlighted.spans()) {
spans.add(Span.styled(s.content(), s.style().patch(selBg)));
}
} else {
- spans.add(isDeprecated
- ? Span.styled(" ⚠ ", Theme.warning())
- : Span.raw(" "));
+ if (viewErrors.containsKey(lineIndex)) {
+ spans.add(Span.styled(" ✗ ", Theme.error().bold()));
+ } else {
+ spans.add(isDeprecated
+ ? Span.styled(" ⚠ ", Theme.warning())
+ : Span.raw(" "));
+ }
if (!prefix.isEmpty()) {
spans.add(Span.styled(prefix, Style.EMPTY.dim()));
}
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 c02446a472a3..7cb73d095bf7 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
@@ -21,7 +21,14 @@ explorer on the left and a source viewer on the right.
- **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)
+ the EIP of a step, the language of an expression). Before it: the values of
+ the `{{placeholders}}` of the line from the project's .properties files, and
+ where the bean the line refers to is declared
+- The problems of a Camel file are marked as soon as it opens: a red ✗ on
+ their lines and the count in the title; the panel at the bottom says the
+ problem of the selected line, and **F9** goes to the next one
+- **u** — usages: the routes that consume from the endpoint of the line and the
+ steps that send to it (direct:, seda:...), across the project; **Enter**
goes there
- **/** — search in source
- **h** — highlight text
- **n/N** — next/previous match
@@ -99,6 +106,11 @@ project and files of different DSLs (build output and
`src/test` are left out);
Java routes are read without compiling them. The case and otherwise of a
switch link like a `to`. Jump indicators are hidden in plain mode.
+A line that refers to a bean (`bean:name`, `.bean(MyBean.class)`, `ref: name`,
+`#class:com.foo.MyBean`...) shows a **↵ name** indicator when the project
+declares it (`@BindToRegistry`, `@Named`, `@Component`, `@Bean`, or the beans
of
+a YAML or XML file); **Enter** goes to its declaration.
+
## Go to Route
- **g** — open a filterable popup listing all routes of the project's source
files.
Type to fuzzy-filter by route ID or endpoint URI, then press **Enter** to
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java
new file mode 100644
index 000000000000..8dd034cf3d80
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.file.Files;
+import java.nio.file.Path;
+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 org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What the source editor tells about a line from the rest of the project:
problems marked as soon as a file opens, the
+ * values of property placeholders, where a bean is declared (and a jump to
it), and where an endpoint is used.
+ */
+class SourceEditorProjectInfoTest {
+
+ @TempDir
+ Path dir;
+
+ private static SourceTab newTab() {
+ return new SourceTab(new MonitorContext(new
AtomicReference<>(List.of()), new AtomicReference<>(List.of())));
+ }
+
+ @Test
+ void theProblemsOfAFileAreMarkedWhenItOpens() throws Exception {
+ Path file = Files.writeString(dir.resolve("MyRoute.java"),
"a\nb\nc\nd\n");
+ SourceViewer viewer = new SourceViewer();
+ viewer.setRouteValidator(content -> List.of("Line 2: timer: Unknown
option 'peroid'", "Line 4: oops"));
+ viewer.loadFile(file);
+
+ assertThat(viewer.viewErrors()).containsOnlyKeys(1, 3);
+ assertThat(viewer.viewErrors().get(1)).contains("peroid");
+
+ // F9 goes to the next problem in the view, and around to the first
+ viewer.goToLine(0);
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F9));
+ assertThat(viewer.getSelectedLine()).isEqualTo(1);
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F9));
+ assertThat(viewer.getSelectedLine()).isEqualTo(3);
+
+ // the editor starts with them, before the first change
+ viewer.enterEditMode();
+ assertThat(viewer.inlineErrors()).containsOnlyKeys(1, 3);
+ }
+
+ @Test
+ void aFileWithoutProblemsHasNoMarkers() throws Exception {
+ Path file = Files.writeString(dir.resolve("MyRoute.java"), "a\n");
+ SourceViewer viewer = new SourceViewer();
+ viewer.setRouteValidator(content -> List.of());
+ viewer.loadFile(file);
+ assertThat(viewer.viewErrors()).isEmpty();
+ }
+
+ @Test
+ void thePlaceholdersOfALineAreExplained() throws Exception {
+ Files.writeString(dir.resolve("application.properties"),
"orders.period = 500\n");
+ SourceEditAssist assist = new SourceEditAssist(
+ new MonitorContext(new AtomicReference<>(List.of()), new
AtomicReference<>(List.of())));
+ assist.setRootDir(dir);
+
+ List<SourceViewer.DocEntry> docs = assist.placeholderDocs(
+
"from(\"timer:orders?period={{orders.period}}&delay={{orders.delay:100}}&x={{env:HOME}}\")");
+
assertThat(docs).extracting(SourceViewer.DocEntry::text).containsExactly(
+ "{{orders.period}} = 500 (application.properties)",
+ "{{orders.delay}} is not set in the project's properties: the
default 100 is used",
+ "{{env:HOME}} is read from the environment when the route
starts");
+ assertThat(docs.get(0).title()).isEqualTo("Placeholder");
+ assertThat(assist.placeholderDocs("from(\"timer:orders\")")).isEmpty();
+ }
+
+ @Test
+ void theBeansARouteRefersToAreFound() throws Exception {
+ Files.writeString(dir.resolve("OrderService.java"), """
+ package com.acme;
+
+ import org.apache.camel.BindToRegistry;
+
+ @BindToRegistry("orders")
+ public class OrderService {
+ public String ship(String body) {
+ return body;
+ }
+ }
+ """);
+ Files.writeString(dir.resolve("beans.camel.yaml"), """
+ - beans:
+ - name: audit
+ type: "#class:com.acme.Audit"
+ """);
+ ProjectBeans beans =
ProjectBeans.scan(List.of(dir.resolve("OrderService.java"),
dir.resolve("beans.camel.yaml")));
+
+ assertThat(beans.refOn("
.to(\"bean:orders?method=ship\")").line()).isEqualTo(4);
+ assertThat(beans.refOn(" .bean(OrderService.class,
\"ship\")").type()).isEqualTo("com.acme.OrderService");
+ assertThat(beans.refOn("
.bean(\"orders\")").label()).isEqualTo("orders");
+ assertThat(beans.refOn(" ref:
audit").filePath()).endsWith("beans.camel.yaml");
+ assertThat(beans.refOn(" ref:
audit").type()).isEqualTo("com.acme.Audit");
+ assertThat(beans.refOn(" <bean
ref=\"orders\"/>").label()).isEqualTo("orders");
+ assertThat(beans.refOn(" .to(\"log:info\")")).isNull();
+ assertThat(beans.refOn(" .to(\"bean:unknown\")")).isNull();
+ assertThat(ProjectBeans.describe(beans.refOn("
.to(\"bean:orders\")")))
+ .isEqualTo("orders (com.acme.OrderService) is declared in
OrderService.java:5 — Enter goes there");
+ }
+
+ @Test
+ void aRouteLineThatRefersToABeanJumpsToIt() throws Exception {
+ Files.writeString(dir.resolve("OrderService.java"), """
+ package com.acme;
+
+ @org.apache.camel.BindToRegistry("orders")
+ public class OrderService {
+ }
+ """);
+ Path route = Files.writeString(dir.resolve("route.camel.yaml"), """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: bean:orders
+ """);
+ SourceTab tab = newTab();
+ assertThat(tab.loadDirectory(dir)).isTrue();
+
+ Map<Integer, SourceViewer.JumpLink> links =
tab.computeJumpLinks(route);
+ assertThat(links.get(5).routeId()).isEqualTo("orders");
+ assertThat(links.get(5).filePath()).endsWith("OrderService.java");
+ assertThat(links.get(5).targetLine()).isEqualTo(2);
+ }
+
+ @Test
+ void theUsagesOfAnEndpointAreItsConsumersAndProducers() throws Exception {
+ Files.writeString(dir.resolve("orders.camel.yaml"), """
+ - route:
+ id: orders
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: direct:billing
+ - route:
+ id: refunds
+ from:
+ uri: timer:refund
+ steps:
+ - to:
+ uri: direct:billing
+ """);
+ Files.writeString(dir.resolve("billing.camel.yaml"), """
+ - route:
+ id: billing
+ from:
+ uri: direct:billing
+ steps:
+ - to:
+ uri: log:billing
+ """);
+ SourceTab tab = newTab();
+ assertThat(tab.loadDirectory(dir)).isTrue();
+ tab.computeJumpLinks(dir.resolve("billing.camel.yaml"));
+
+ assertThat(tab.usagesOf("direct:billing"))
+ .extracting(i -> i.routeId() + " " + i.fromUri() + " @" +
(i.fromLine() + 1))
+ .containsExactlyInAnyOrder(
+ "billing from direct:billing @3",
+ "orders to direct:billing @7",
+ "refunds to direct:billing @14");
+ }
+}