This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/source-editor-fixes in repository https://gitbox.apache.org/repos/asf/camel.git
commit 63f103691ee17c25f1acc3c26dacd99ee6be5327 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 12:18:28 2026 +0200 chore: camel-jbang - source editor fixes: go to node in Java and XML, reload after an AI write, edit title with errors, quick fix of a Simple function - Ctrl+G lists the routes of Java and XML files too (read into the Camel model), not only YAML - a file an AI tool writes is shown again when the viewer shows it and it is not being edited - the error count no longer replaces the Edit [file] title (a block has one top title) - a Simple function the error names the right one of (did you mean ${body}?) has a quick fix 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 | 3 +- .../dsl/jbang/core/commands/ai/QuickFixes.java | 14 +- .../dsl/jbang/core/commands/ai/QuickFixesTest.java | 17 +++ .../dsl/jbang/core/commands/tui/CamelMonitor.java | 10 ++ .../dsl/jbang/core/commands/tui/McpFacade.java | 10 ++ .../core/commands/tui/ModelRouteNodeScanner.java | 153 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/SourceTab.java | 33 ++++- .../dsl/jbang/core/commands/tui/SourceViewer.java | 24 +++- .../core/commands/tui/YamlRouteNodeScanner.java | 2 +- .../src/main/resources/tui/help/source.md | 3 +- .../commands/tui/ModelRouteNodeScannerTest.java | 105 ++++++++++++++ .../core/commands/tui/SourceViewerReloadTest.java | 82 +++++++++++ 12 files changed, 443 insertions(+), 13 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 5e8cc15aa36c..f6c5b93581dd 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -241,7 +241,8 @@ An XML file with problems is not saved, as a YAML file; a Java file is saved and the application's code, and a check that cannot know something (a value computed at runtime) must not stop you. When the problem on the line of the cursor says its fix (an option that is a typo of another, an enum value a letter -off, a `to` that should be a `toD`, a Simple expression `+${key}+` where the property placeholder `{{key}}` is meant), +off, a `to` that should be a `toD`, a Simple expression `+${key}+` where the property placeholder `{{key}}` is meant, a +Simple function the error names the right one of, such as `+${bdy}+` for `+${body}+`), the Error panel shows it and *Shift+F9* applies it; *Ctrl+Z* takes it back. `camel_validate_source` gives the same fixes to an agent, as `fixes` with the text to find and its replacement. diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java index b8065931d337..2c8d330cde10 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java @@ -25,8 +25,9 @@ import java.util.regex.Pattern; /** * The fix of a problem the validation reports, when the problem says it: an option that is a typo of another (Did you * mean: [period]), an enum value of the wrong case, a to that needs toD, a Simple expression where a property - * placeholder is meant. The fix is a replacement of text on the line of the problem, which an editor applies with one - * key and an agent applies as an edit, whatever the DSL (YAML, Java, XML) the line is written in. + * placeholder is meant, a Simple function the error names the right one of (did you mean ${body}?). The fix is a + * replacement of text on the line of the problem, which an editor applies with one key and an agent applies as an edit, + * whatever the DSL (YAML, Java, XML) the line is written in. * <p/> * A fix is only given when it is certain: one suggestion, a value that matches but for its case or a letter or two. */ @@ -58,6 +59,9 @@ public final class QuickFixes { private static final Pattern SIMPLE_AS_PLACEHOLDER = Pattern.compile( "([\\w.-]+)=(\\$\\{[^}]*\\}) is a Simple expression, which an endpoint option is not evaluated as.*\\{\\{([^}]+)\\}\\}"); private static final Pattern DYNAMIC_TO = Pattern.compile("holds an expression \\(\\$\\{"); + private static final Pattern UNKNOWN_FUNCTION = Pattern.compile("Unknown function: (.+?) \\((?:the argument goes in" + + " parentheses: )?(?:did you mean |function names are" + + " case sensitive: )(\\$\\{.+\\})\\??\\)"); private QuickFixes() { } @@ -105,6 +109,12 @@ public final class QuickFixes { String placeholder = "{{" + m.group(3) + "}}"; return new Fix(m.group(2) + " → " + placeholder, m.group(2), placeholder); } + m = UNKNOWN_FUNCTION.matcher(message); + if (m.find()) { + // ${bdy} (did you mean ${body}?): the function the hint names, in place of the one written + String written = "${" + m.group(1) + "}"; + return new Fix(written + " → " + m.group(2), written, m.group(2)); + } if (DYNAMIC_TO.matcher(message).find() && message.contains("toD")) { // the step itself: .to( in Java, <to in XML, to: in YAML for (String[] form : new String[][] { { ".to(", ".toD(" }, { "<to ", "<toD " }, { "to:", "toD:" } }) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java index 7c73c78e2213..a037a4458122 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java @@ -81,6 +81,23 @@ class QuickFixesTest { """)).isEqualTo("<from uri=\"timer:tick?period=1000\"/>"); } + @Test + void aSimpleFunctionTheErrorNamesTheRightOneOf() { + assertThat(fixed("MyRoute.java", java( + " from(\"timer:tick\").filter(simple(\"${bdy} > 3\")).to(\"seda:out\");"))) + .isEqualTo("from(\"timer:tick\").filter(simple(\"${body} > 3\")).to(\"seda:out\");"); + assertThat(fixed("MyRoute.java", java( + " from(\"timer:tick\").setBody(simple(\"${Body}\")).to(\"seda:out\");"))) + .isEqualTo("from(\"timer:tick\").setBody(simple(\"${body}\")).to(\"seda:out\");"); + } + + @Test + void aSimpleFunctionWithoutASuggestionHasNoFix() { + String error = "Line 6: Simple syntax error: Unknown function: zzzzqqq (the functions are documented on the" + + " simple language page (functions))"; + assertThat(QuickFixes.fixFor(error, " .setBody(simple(\"${zzzzqqq}\"))")).isNull(); + } + @Test void aTypoOfAYamlOption() { assertThat(fixed("route.camel.yaml", """ diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index aea932c473bc..f81f03f88628 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -193,6 +193,8 @@ public class CamelMonitor extends CamelCommand { private final EditReplay editReplay = new EditReplay(); // a live write waiting to start on the UI thread, and the promise its tool thread waits on private volatile McpFacade.FileWrite pendingReplay; + /** The files AI tools wrote, for the source viewer to show again on the UI thread. */ + private final Queue<Path> writtenFiles = new ConcurrentLinkedQueue<>(); private volatile CompletableFuture<McpFacade.ReplayOutcome> pendingReplayOutcome; // both are read by the tool thread to decide whether a write can be replayed private volatile McpFacade.FileWrite activeReplay; @@ -813,6 +815,11 @@ public class CamelMonitor extends CamelCommand { return parked != null && activeReplayOutcome == null ? parked.file() : null; } + @Override + public void fileWritten(Path file) { + writtenFiles.add(file); + } + @Override public boolean confirmFileWrite(McpFacade.FileWrite request) { CompletableFuture<Boolean> answer = new CompletableFuture<>(); @@ -1867,6 +1874,9 @@ public class CamelMonitor extends CamelCommand { drawOverlay.tick(now); captionOverlay.tick(now); tickEditReplay(now); + for (Path written = writtenFiles.poll(); written != null; written = writtenFiles.poll()) { + tabRegistry.sourceTab().reloadIfShowing(written); + } recordingManager.tickRecentKeys(now); boolean anyDiagramShowing = tabRegistry.routesTab().isShowDiagram() || tabRegistry.diagramTab().isShowDiagram(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index bc2a582a4c41..9eb8cce9bed8 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -116,6 +116,13 @@ class McpFacade { default String parkedReplayFile() { return null; } + + /** + * Tells that a tool wrote a file, so a view of the file shows what is in it now. Called on the calling (tool) + * thread. + */ + default void fileWritten(Path file) { + } } /** How file writes requested by tools are handled; set by the user with /write in the AI panel. */ @@ -1209,6 +1216,9 @@ class McpFacade { } catch (IOException e) { return writeError("Failed to write " + filePath + ": " + e.getMessage()); } + if (bridge != null) { + bridge.fileWritten(filePath); + } JsonObject result = new JsonObject(); result.put("status", exists ? "overwritten" : "created"); result.put("file", file); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScanner.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScanner.java new file mode 100644 index 000000000000..9088cee8e209 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScanner.java @@ -0,0 +1,153 @@ +/* + * 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.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import org.apache.camel.LineNumberAware; +import org.apache.camel.NamedNode; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.dsl.jbang.core.commands.ai.RouteModel; +import org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.EntryKind; +import org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.NodeEntry; +import org.apache.camel.model.ChoiceDefinition; +import org.apache.camel.model.OtherwiseDefinition; +import org.apache.camel.model.ProcessorDefinition; +import org.apache.camel.model.RouteDefinition; +import org.apache.camel.model.TryDefinition; +import org.apache.camel.model.WhenDefinition; + +/** + * The nodes of the Java and XML DSL routes of a file for Go to Node (Ctrl+G) of the Source tab, as the YAML scanner + * gives them for a YAML file. The file is read into the Camel model as the route checks read it: a Java source by the + * Java DSL parser, without compiling or running it. + */ +final class ModelRouteNodeScanner { + + private static final int MAX_DEPTH = 50; + + private ModelRouteNodeScanner() { + } + + /** + * The nodes of the routes of a Java or XML DSL file; empty when the file is neither or cannot be read. + * + * @param javaSources the Java sources of the project by path, for the constants of other classes + */ + static List<NodeEntry> scan( + String filePath, String fileName, String content, Map<String, Supplier<String>> javaSources, + CamelCatalog catalog) { + RouteModel model; + try { + model = RouteModel.read(fileName, content, catalog, javaSources); + } catch (RuntimeException e) { + return List.of(); + } + if (model == null) { + return List.of(); + } + List<NodeEntry> answer = new ArrayList<>(); + for (RouteDefinition r : model.routes()) { + if (r.getInput() == null || r.getInput().getUri() == null) { + continue; + } + String fromUri = DiagramSupport.stripQueryParams(JavaRouteScanner.uri(r.getInput().getUri())); + int fromLine = line(r.getInput(), line(r, 0)); + String routeId = YamlRouteNodeScanner.resolveRouteId(r.getRouteId(), fromUri); + answer.add(new NodeEntry(EntryKind.ROUTE, routeId, fromUri, "route", fromUri, filePath, fromLine, 0, fromLine)); + Set<Object> seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (ProcessorDefinition<?> p : r.getOutputs()) { + walk(p, routeId, filePath, fromLine, 1, answer, seen); + } + } + return answer; + } + + private static void walk( + NamedNode p, String routeId, String filePath, int fromLine, int depth, List<NodeEntry> answer, + Set<Object> seen) { + if (p == null || depth > MAX_DEPTH || !seen.add(p)) { + return; + } + answer.add(new NodeEntry( + EntryKind.PROCESSOR, routeId, null, p.getShortName(), label(p), filePath, line(p, fromLine), depth, + fromLine)); + for (NamedNode child : children(p)) { + walk(child, routeId, filePath, fromLine, depth + 1, answer, seen); + } + } + + /** The nodes under a node: the when and otherwise of a choice are nodes of their own, as the diagram shows them. */ + private static List<NamedNode> children(NamedNode p) { + List<NamedNode> answer = new ArrayList<>(); + if (p instanceof ChoiceDefinition choice) { + answer.addAll(choice.getWhenClauses()); + if (choice.getOtherwise() != null) { + answer.add(choice.getOtherwise()); + } + } else if (p instanceof WhenDefinition when) { + answer.addAll(when.getOutputs()); + } else if (p instanceof OtherwiseDefinition otherwise) { + answer.addAll(otherwise.getOutputs()); + } else if (p instanceof ProcessorDefinition<?> pd) { + answer.addAll(pd.getOutputs()); + if (pd instanceof TryDefinition t) { + answer.addAll(t.getCatchClauses()); + if (t.getFinallyClause() != null) { + answer.add(t.getFinallyClause()); + } + } + } + return answer; + } + + /** The label of a node without its EIP name: filter[simple{...}] gives simple{...}. */ + static String label(NamedNode p) { + if (p instanceof OtherwiseDefinition || p instanceof ChoiceDefinition) { + // the when and otherwise of a choice are listed under it + return ""; + } + String label; + try { + label = p.getLabel(); + } catch (RuntimeException e) { + return ""; + } + if (label == null) { + return ""; + } + String name = p.getShortName(); + if (label.startsWith(name + "[") && label.endsWith("]")) { + label = label.substring(name.length() + 1, label.length() - 1); + } + return label.replace('\n', ' '); + } + + /** The line of a node from 0, or the fallback when the model does not know it. */ + private static int line(Object node, int fallback) { + if (node instanceof LineNumberAware n && n.getLineNumber() > 0) { + return n.getLineNumber() - 1; + } + return fallback; + } +} 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..9581683ad4c3 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 @@ -1163,6 +1163,17 @@ class SourceTab extends AbstractTab { return answer; } + /** The Java sources among the files by path, for the constants a Java route takes from another class. */ + private static Map<String, Supplier<String>> javaSources(List<Path> files) { + Map<String, Supplier<String>> answer = new LinkedHashMap<>(); + for (Path f : files) { + if (f.getFileName().toString().endsWith(".java")) { + answer.put(f.toString(), () -> readQuietly(f)); + } + } + return answer; + } + private static boolean isRouteSourceName(String name) { String lower = name.toLowerCase(); return lower.endsWith(".java") || lower.endsWith(".xml") || lower.endsWith(".yaml") || lower.endsWith(".yml"); @@ -1209,17 +1220,33 @@ class SourceTab extends AbstractTab { } } + /** Shows a file again when the viewer shows it and it is not being edited: a file an AI tool wrote. */ + void reloadIfShowing(Path file) { + sourceViewer.reloadIfShowing(file); + } + private List<YamlRouteNodeScanner.NodeEntry> buildSourceNodeIndex() { List<YamlRouteNodeScanner.NodeEntry> nodes = new ArrayList<>(); + Map<String, Supplier<String>> javaSources = null; for (FilesBrowser.FileEntry entry : entries) { if (entry.directory()) { continue; } Path path = Path.of(entry.path()); - if (!isCamelSourceFile(path) || !SourceEditAssist.isYamlFile(path)) { - continue; + if (isJavaRouteFile(path) + || isCamelSourceFile(path) && path.getFileName().toString().toLowerCase().endsWith(".xml")) { + String content = readQuietly(path); + if (content != null) { + if (javaSources == null) { + javaSources = javaSources(routeSources()); + } + nodes.addAll(ModelRouteNodeScanner.scan( + path.toString(), path.getFileName().toString(), content, javaSources, + ArchitectureView.catalog())); + } + } else if (isCamelSourceFile(path) && SourceEditAssist.isYamlFile(path)) { + nodes.addAll(YamlRouteNodeScanner.scanFile(path)); } - nodes.addAll(YamlRouteNodeScanner.scanFile(path)); } return nodes; } 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 4da19e05f584..79d3cf85ee21 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 @@ -437,6 +437,20 @@ class SourceViewer { return selectedLine; } + /** + * Shows the file again, at the same line, when it is the file shown and it is not being edited: a file an AI tool + * wrote while the viewer showed the old content. An edit in progress is left alone, as it holds the user's work. + */ + void reloadIfShowing(Path file) { + if (editMode || editableFile == null || file == null + || !editableFile.toAbsolutePath().normalize().equals(file.toAbsolutePath().normalize())) { + return; + } + int line = selectedLine; + loadFile(editableFile); + goToLine(Math.min(line, Math.max(0, getLineCount() - 1))); + } + int getLineCount() { if (editMode) { return editState.lineCount(); @@ -1977,6 +1991,11 @@ class SourceViewer { } } } + if (!diffOverlay && !visibleErrors.isEmpty()) { + // a block has one title at the top: the error count goes on the line of the file name, not instead of it + Style errorStyle = Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66)); + titleSpans.add(Span.styled(" errors: " + visibleErrors.size() + " ", errorStyle)); + } Title posTitle; if (diffOverlay) { posTitle = Title.from( @@ -1996,11 +2015,6 @@ class SourceViewer { blockBuilder.borders(Borders.ALL) .title(Title.from(Line.from(titleSpans))) .titleBottom(posTitle); - if (!visibleErrors.isEmpty()) { - Style errorStyle = Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66)); - blockBuilder.title(Title.from(Line.from( - Span.styled(" errors: " + visibleErrors.size() + " ", errorStyle))).right()); - } } if (borderStyle != null) { blockBuilder.borderStyle(borderStyle); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java index 01ec6ec7abd5..fc9045210963 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java @@ -208,7 +208,7 @@ class YamlRouteNodeScanner { EntryKind.ROUTE, resolvedId, fromUri, "route", fromUri, filePath, fromLine, 0, fromLine)); } - private static String resolveRouteId(String routeId, String fromUri) { + static String resolveRouteId(String routeId, String fromUri) { if (routeId != null && !routeId.isEmpty()) { return routeId; } 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 7f590bffe081..9d35b8c8af13 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 @@ -42,7 +42,8 @@ explorer on the left and a source viewer on the right. - **F9** — jump to next validation error - **Shift+F9** — apply the fix of the problem on the cursor line, when the problem says it (an option typo, an enum value a letter off, `to` that should be - `toD`, `${key}` where `{{key}}` is meant); the Error panel shows the fix + `toD`, `${key}` where `{{key}}` is meant, a Simple function the error names + the right one of, such as `${bdy}` → `${body}`); the Error panel shows the fix - 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 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScannerTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScannerTest.java new file mode 100644 index 000000000000..9b16528aabdf --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScannerTest.java @@ -0,0 +1,105 @@ +/* + * 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.List; +import java.util.Map; + +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.EntryKind; +import org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.NodeEntry; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Go to Node (Ctrl+G) lists the routes of Java and XML files too, read into the Camel model. + */ +class ModelRouteNodeScannerTest { + + /** Each node as depth, type[label] @ line (from 1). */ + private static List<String> describe(List<NodeEntry> nodes) { + return nodes.stream() + .map(n -> n.indent() + " " + n.type() + "[" + n.label() + "] @" + (n.lineIndex() + 1)) + .toList(); + } + + @Test + void theNodesOfAJavaRoute() { + String java = """ + import org.apache.camel.builder.RouteBuilder; + + public class OrderRoute extends RouteBuilder { + @Override + public void configure() throws Exception { + from("timer:orders?period=500") + .routeId("orders") + .filter(simple("${body} > 3")) + .to("log:big") + .end() + .choice() + .when(simple("${body} == 9")) + .throwException(new IllegalStateException("Out of stock")) + .otherwise() + .to("seda:shipping"); + } + } + """; + List<NodeEntry> nodes = ModelRouteNodeScanner.scan( + "/p/OrderRoute.java", "OrderRoute.java", java, Map.of(), new DefaultCamelCatalog()); + + assertThat(nodes.get(0).kind()).isEqualTo(EntryKind.ROUTE); + assertThat(nodes.get(0).routeId()).isEqualTo("orders"); + assertThat(nodes.get(0).fromUri()).isEqualTo("timer:orders"); + assertThat(nodes.get(0).lineIndex()).isEqualTo(5); + assertThat(nodes.get(0).filePath()).isEqualTo("/p/OrderRoute.java"); + assertThat(describe(nodes.subList(1, nodes.size()))).containsExactly( + "1 filter[simple{${body} > 3}] @8", + "2 to[log:big] @9", + "1 choice[] @11", + "2 when[simple{${body} == 9}] @12", + "3 throwException[java.lang.IllegalStateException] @13", + "2 otherwise[] @14", + "3 to[seda:shipping] @15"); + } + + @Test + void theNodesOfAnXmlRoute() { + String xml = """ + <routes xmlns="http://camel.apache.org/schema/xml-io"> + <route id="shipping"> + <from uri="seda:shipping"/> + <log message="Shipping ${body}"/> + <to uri="log:shipped"/> + </route> + </routes> + """; + List<NodeEntry> nodes = ModelRouteNodeScanner.scan( + "/p/routes.xml", "routes.xml", xml, Map.of(), new DefaultCamelCatalog()); + + assertThat(nodes.get(0).routeId()).isEqualTo("shipping"); + assertThat(describe(nodes.subList(1, nodes.size()))).containsExactly( + "1 log[Shipping ${body}] @4", + "1 to[log:shipped] @5"); + } + + @Test + void aFileThatIsNotARouteHasNoNodes() { + assertThat(ModelRouteNodeScanner.scan( + "/p/Util.java", "Util.java", "public class Util {}", Map.of(), new DefaultCamelCatalog())).isEmpty(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerReloadTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerReloadTest.java new file mode 100644 index 000000000000..574dc4a158cf --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerReloadTest.java @@ -0,0 +1,82 @@ +/* + * 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.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A file an AI tool wrote is shown again in the viewer, unless it is being edited. + */ +class SourceViewerReloadTest { + + @TempDir + Path tempDir; + + @Test + void theViewerShowsWhatTheAiWrote() throws IOException { + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, "line0\nline1 ${bdy}\nline2\n"); + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.goToLine(1); + + Files.writeString(file, "line0\nline1 ${body}\nline2\n"); + viewer.reloadIfShowing(file); + + viewer.enterEditMode(); + assertThat(viewer.editState().text()).contains("${body}").doesNotContain("${bdy}"); + assertThat(viewer.getSelectedLine()).isEqualTo(1); + } + + @Test + void anEditInProgressIsLeftAlone() throws IOException { + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, "mine\n"); + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + Files.writeString(file, "theirs\n"); + viewer.reloadIfShowing(file); + + assertThat(viewer.isEditMode()).isTrue(); + assertThat(viewer.editState().text()).contains("mine"); + } + + @Test + void anotherFileIsNotReloaded() throws IOException { + Path file = tempDir.resolve("a.camel.yaml"); + Path other = tempDir.resolve("b.camel.yaml"); + Files.writeString(file, "a\n"); + Files.writeString(other, "b\n"); + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + + Files.writeString(file, "changed\n"); + viewer.reloadIfShowing(other); + + viewer.enterEditMode(); + assertThat(viewer.editState().text()).contains("a").doesNotContain("changed"); + } +}
