This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/source-editor-ideas in repository https://gitbox.apache.org/repos/asf/camel.git
commit 61ea0cf8930255beac15014677993e5aa910fd2b Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 12:51:56 2026 +0200 chore: camel-jbang - source editor: problems marked when a file opens, placeholder values and bean jumps, usages of an endpoint - the problems of a Camel file are marked as soon as it opens (view and editor), F9 goes to the next - the quick doc starts with the values of the property placeholders of the line, and where its bean is declared - a line that refers to a bean jumps to its declaration (@BindToRegistry, @Named, @Component, @Bean, YAML/XML beans) - u lists the usages of the endpoint of the line: the routes consuming from it and the steps sending to it Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[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 4552e5cefb1e..71babec14554 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -213,6 +213,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. @@ -237,6 +244,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 4c6cde703bac..dc6b9702ec87 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(); @@ -268,6 +271,11 @@ class SourceTab extends AbstractTab { return true; } + if (focusOnViewer && sourceViewer.isVisible() && !sourceViewer.isEditMode() && ke.isChar('u')) { + openUsages(); + return true; + } + if (!focusOnViewer) { return handleFileListKey(ke); } @@ -437,6 +445,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 { @@ -837,14 +848,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) @@ -853,14 +864,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); @@ -1349,7 +1361,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(); } @@ -1475,6 +1551,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 67dd394cbc8c..5237c8bbbf34 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 @@ -196,6 +196,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; @@ -528,6 +532,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; @@ -922,6 +930,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(); @@ -1518,9 +1529,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) { @@ -1549,6 +1564,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) { @@ -1571,7 +1592,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; } /** @@ -1894,7 +1933,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()); @@ -1905,7 +1954,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(); @@ -2330,6 +2379,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"); } @@ -2535,6 +2587,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); @@ -2548,6 +2601,7 @@ class SourceViewer { markdownMode = false; rawMarkdownContent = null; editableFile = null; + viewErrors = Collections.emptyMap(); } } @@ -2561,6 +2615,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(); @@ -2775,6 +2830,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)); } @@ -2973,15 +3033,21 @@ 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))); } 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 4f71eee20eb8..cd710dd6efcd 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 @@ -92,6 +99,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"); + } +}
