This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25256 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 241ba3edc9a4681288a8748032002d5c1dece706 Author: Claus Ibsen <[email protected]> AuthorDate: Fri Oct 2 08:24:05 2026 +0200 CAMEL-25256: camel-jbang - source editor: refactorings (Ctrl+R) for Java and XML routes The refactoring menu of the source editor worked in YAML routes only. Now in Java and XML routes too: - Replace URI: the endpoint URI of the line (the string of from, to, toD, wireTap, enrich, pollEnrich, poll in Java; their uri attribute in XML, entities handled); - Extract to property: the string literal (Java) or attribute value (XML) at the cursor becomes a {{key}} placeholder, the value written to application.properties, in src/main/resources for a Maven or Gradle layout; - Extract to new file (XML): the step block at the cursor becomes a route from direct:name in name.camel.xml (added to it when it exists), a to in its place. Java has no extract to file: a step of a fluent chain is no block with clear edges. Docs, help and a screenshot updated. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../images/jbang/camel-tui-source-xml-refactor.png | Bin 0 -> 266917 bytes .../ROOT/pages/camel-jbang-tui-source-editor.adoc | 13 +- .../jbang/core/commands/tui/RouteRefactorings.java | 248 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/SourceViewer.java | 137 +++++++++++- .../src/main/resources/tui/help/source.md | 4 +- .../core/commands/tui/RouteRefactoringsTest.java | 189 ++++++++++++++++ 6 files changed, 585 insertions(+), 6 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-refactor.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-refactor.png new file mode 100644 index 000000000000..6bcedc95c0ca Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-refactor.png differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc index f0b0721d5902..6996a256a66a 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc @@ -29,10 +29,14 @@ The left panel lists the files of the integration's project, and the right panel highlighting. *Tab* switches between the two panels, and you can drag the border between them with the mouse. Press *Enter* to view a file, or *F4* to edit it. The editor is a plain-text editor with helpers for Camel routes: -undo and redo, moving and duplicating YAML blocks, smart *Home*, and word navigation. In YAML routes, *Ctrl+R* opens a -refactoring menu for the current line. *Ctrl+S* saves and keeps editing, *F5* saves and closes, and *Esc* cancels +undo and redo, moving and duplicating YAML blocks, smart *Home*, and word navigation. *Ctrl+R* opens a refactoring menu +for the current line: replace the endpoint URI of the line, extract the value at the cursor to a property (the value +goes to `application.properties`, in `src/main/resources` for a Maven layout, and a `+{{key}}+` placeholder takes its +place), and extract a step to a new route file, called with `direct:` (YAML and XML routes). *Ctrl+S* saves and keeps editing, *F5* saves and closes, and *Esc* cancels (and asks before it discards unsaved changes). A running integration in dev mode reloads the saved file. +image::jbang/camel-tui-source-xml-refactor.png[Ctrl+R on a log step of an XML route: extract it to a new file or its message to a property] + == Live Run Data While the integration runs, a column after the line numbers shows what the processors on each line do: the exchanges @@ -176,7 +180,8 @@ project: Values are completed for options with a fixed set of values (enums, `true`/`false`) and `+{{placeholders}}+`. * *Simple expressions* -- header, property and variable names are the ones the file sets or reads and the headers of the components it uses, not the ones other routes or your own code set at runtime. -* Nothing is completed from your own Java classes and beans, and there are no refactorings (rename, extract) or imports. +* Nothing is completed from your own Java classes and beans, and there are no imports or refactorings beyond the + *Ctrl+R* menu (no rename, and no extract to file in Java, whose steps are no block with clear edges). For more than that, an AI coding agent is the more powerful companion: working together with you in the TUI, it knows Camel, reads and writes the whole project, runs the routes and checks them, and explains what it does as it goes. See @@ -299,7 +304,7 @@ mode, which hides the line numbers and borders for easy copying. | *Ctrl+K* | Delete current line | *Ctrl+Left/Right* | Word navigation | *Home* | Smart home (content indent / column 0) -| *Ctrl+R* | Refactoring menu (YAML) +| *Ctrl+R* | Refactoring menu: replace URI, extract to property, extract a step to a new file (YAML, XML) | *F9* | Next problem | *Shift+F9* | Apply the quick fix of the problem on the line | *Shift+F8* | Ask the AI to fix the problem on the line diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactorings.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactorings.java new file mode 100644 index 000000000000..66cea3eed012 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactorings.java @@ -0,0 +1,248 @@ +/* + * 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.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The refactorings of the source editor (Ctrl+R) for Java and XML routes (CAMEL-25256), as {@link SourceRefactorings} + * has them for YAML: the endpoint URI of a line, the string literal (Java) or attribute value (XML) at the cursor, and + * the element block of an XML step, with what replaces them. + */ +final class RouteRefactorings { + + /** + * A quoted value on a line: where its text starts and ends (inside the quotes), and the text as the route means it + * (escapes and entities undone). + */ + record Value(int start, int end, String text) { + } + + /** The DSL methods (Java) and elements (XML) whose argument or uri attribute is an endpoint URI. */ + private static final String ENDPOINT_CALLS = "from|to|toD|wireTap|enrich|pollEnrich|poll"; + private static final Pattern JAVA_URI = Pattern.compile( + "\\b(?:" + ENDPOINT_CALLS + ")\\s*\\(\\s*\"((?:[^\"\\\\]|\\\\.)*)\""); + private static final Pattern XML_URI = Pattern.compile( + "<(?:[\\w-]+:)?(?:" + ENDPOINT_CALLS + ")\\b[^>]*?\\suri\\s*=\\s*(\"([^\"]*)\"|'([^']*)')"); + private static final Pattern XML_ATTRIBUTE = Pattern.compile("[\\w:.-]+\\s*=\\s*(\"([^\"]*)\"|'([^']*)')"); + + /** Elements that are no step to extract: the route, its input, and the parts of the steps that hold them. */ + private static final Set<String> NOT_EXTRACTABLE = Set.of( + "routes", "route", "camel", "beans", "from", "when", "otherwise", "doCatch", "doFinally", "onFallback", + "expression", "simple", "constant", "header", "exchangeProperty", "variable", "jsonpath", "xpath", + "groovy", "language", "method", "tokenize", "jq", "ref", "datasonnet", "spel", "mvel", "ognl", "js", + "python", "java", "wasm", "xquery", "xtokenize", "hl7terser", "jactl", "csimple", "joor"); + + private RouteRefactorings() { + } + + // ---- the endpoint URI of a line ---- + + /** The endpoint URI on the line: the string of from, to, toD... in Java, the uri attribute of those in XML. */ + static Value uri(String dsl, String line) { + if (line == null) { + return null; + } + Matcher m = ("xml".equals(dsl) ? XML_URI : JAVA_URI).matcher(line); + if (!m.find()) { + return null; + } + if ("xml".equals(dsl)) { + int group = m.group(2) != null ? 2 : 3; + return new Value(m.start(group), m.end(group), xmlDecode(m.group(group))); + } + return new Value(m.start(1), m.end(1), javaUnescape(m.group(1))); + } + + // ---- the value at the cursor ---- + + /** The string literal (Java) or attribute value (XML) the cursor is on; null when it is on none. */ + static Value valueAt(String dsl, String line, int col) { + if (line == null || col < 0) { + return null; + } + if ("xml".equals(dsl)) { + Matcher m = XML_ATTRIBUTE.matcher(line); + while (m.find()) { + int group = m.group(2) != null ? 2 : 3; + // on the attribute name or in its value, the quotes included + if (col >= m.start() && col <= m.end()) { + return new Value(m.start(group), m.end(group), xmlDecode(m.group(group))); + } + } + return null; + } + int open = -1; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (open < 0 && c == '/' && i + 1 < line.length() && line.charAt(i + 1) == '/') { + return null; + } + if (open >= 0 && c == '\\') { + i++; + } else if (c == '"') { + if (open < 0) { + open = i; + } else { + if (col >= open && col <= i + 1) { + return new Value(open + 1, i, javaUnescape(line.substring(open + 1, i))); + } + open = -1; + } + } + } + return null; + } + + /** Whether a value can be extracted to a property: something, and no placeholder already. */ + static boolean isExtractable(Value v) { + return v != null && !v.text().isBlank() && !(v.text().startsWith("{{") && v.text().endsWith("}}")); + } + + /** The line with the value replaced, written as the DSL quotes it (escaped in Java, entities in XML). */ + static String replace(String dsl, String line, Value v, String text) { + String written = "xml".equals(dsl) ? xmlEncode(text) : javaEscape(text); + return line.substring(0, v.start()) + written + line.substring(v.end()); + } + + /** + * The application.properties a property of the route file goes to: src/main/resources of a Maven or Gradle layout, + * else the file's own folder, as camel run reads it. + */ + static Path propertiesFile(Path routeFile) { + Path dir = routeFile.toAbsolutePath().getParent(); + for (Path p = dir; p != null; p = p.getParent()) { + if (p.getFileName() != null && "src".equals(p.getFileName().toString())) { + return p.resolve("main/resources/application.properties"); + } + } + return dir.resolve("application.properties"); + } + + // ---- an XML step block ---- + + /** The rows of an XML element block, its start tag on the first. */ + record Block(int start, int end, String element, int indent) { + } + + private static final Pattern START_TAG = Pattern.compile("^(\\s*)<(?:[\\w-]+:)?([\\w-]+)"); + + /** The step block whose start tag is on the row; null when the row holds none, or no step to extract. */ + static Block xmlStep(List<String> lines, int row) { + if (row < 0 || row >= lines.size()) { + return null; + } + Matcher m = START_TAG.matcher(lines.get(row)); + if (!m.find() || NOT_EXTRACTABLE.contains(m.group(2))) { + return null; + } + // must be inside a route + XmlCompletionContext c = XmlCompletionContext.at(lines, row, m.start(2) - 1); + if (c == null || !c.path().contains("route")) { + return null; + } + int depth = 0; + for (int r = row; r < lines.size(); r++) { + depth += tagBalance(lines.get(r)); + if (depth <= 0) { + return new Block(row, r, m.group(2), m.group(1).length()); + } + } + return null; + } + + /** + * The opening minus the closing tags of a line: self-closing tags, comments and processing instructions count 0. + */ + static int tagBalance(String line) { + int balance = 0; + String s = line.replaceAll("<!--.*?-->", "").replaceAll("<\\?.*?\\?>", ""); + Matcher m = Pattern.compile("<(/?)[\\w:-]+[^>]*?(/?)>").matcher(s); + while (m.find()) { + if (!m.group(1).isEmpty()) { + balance--; + } else if (m.group(2).isEmpty()) { + balance++; + } + } + return balance; + } + + /** A new XML routes file with the block as a route consuming from direct:name. */ + static String xmlRouteFile(String name, List<String> block, int indent) { + StringBuilder sb = new StringBuilder(); + sb.append("<routes xmlns=\"http://camel.apache.org/schema/xml-io\">\n"); + sb.append(xmlRoute(name, block, indent)); + sb.append("</routes>\n"); + return sb.toString(); + } + + /** A route of the block consuming from direct:name, indented as a route of a routes file. */ + static String xmlRoute(String name, List<String> block, int indent) { + StringBuilder sb = new StringBuilder(); + sb.append(" <route id=\"").append(xmlEncode(name)).append("\">\n"); + sb.append(" <from uri=\"direct:").append(xmlEncode(name)).append("\"/>\n"); + for (String line : block) { + String body + = line.length() >= indent && line.substring(0, indent).isBlank() ? line.substring(indent) : line.strip(); + sb.append(body.isBlank() ? "" : " " + body).append('\n'); + } + sb.append(" </route>\n"); + return sb.toString(); + } + + /** An existing XML routes file with the route added before its end tag; null when it has no routes end tag. */ + static String addXmlRoute(String existing, String route) { + int end = existing.lastIndexOf("</routes>"); + if (end < 0) { + return null; + } + return existing.substring(0, end) + route + existing.substring(end); + } + + /** The lines with the block replaced by a to of direct:name, at the block's indent. */ + static List<String> replaceWithTo(List<String> lines, Block block, String name) { + List<String> answer = new ArrayList<>(lines.subList(0, block.start())); + answer.add(" ".repeat(block.indent()) + "<to uri=\"direct:" + xmlEncode(name) + "\"/>"); + answer.addAll(lines.subList(block.end() + 1, lines.size())); + return answer; + } + + // ---- quoting ---- + + static String xmlDecode(String s) { + return s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") + .replace("&", "&"); + } + + static String xmlEncode(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } + + static String javaUnescape(String s) { + return s.replace("\\\"", "\"").replace("\\\\", "\\"); + } + + static String javaEscape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} 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 692fa4bee868..337d19245337 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 @@ -988,7 +988,7 @@ class SourceViewer { applyBlockEdit(YamlBlockEditor.deleteLine(editLines(), editState.cursorRow())); return true; } - if (ke.hasCtrl() && ke.isCharIgnoreCase('r') && isCamelYamlFile()) { + if (ke.hasCtrl() && ke.isCharIgnoreCase('r') && (isCamelYamlFile() || uriCompletion != null)) { openRefactorPopup(); return true; } @@ -2998,6 +2998,11 @@ class SourceViewer { return; } String rawLine = editState.getLine(row); + if (!isCamelYamlFile()) { + // Java and XML routes (CAMEL-25256) + openRouteRefactorPopup(row, rawLine); + return; + } List<RefactorPopup.Action> actions = new ArrayList<>(); // Extract to new file: available on any EIP step block in a YAML route if (isCamelYamlFile()) { @@ -3021,12 +3026,142 @@ class SourceViewer { refactorPopup.open(actions, currentUri); } + /** + * The refactorings of a Java or XML route: replace the endpoint URI of the line, extract the value at the cursor to + * a property, and in XML extract the step block of the line to a new route file. + */ + private void openRouteRefactorPopup(int row, String rawLine) { + String dsl = uriCompletion; + List<RefactorPopup.Action> actions = new ArrayList<>(); + if ("xml".equals(dsl) && RouteRefactorings.xmlStep(editLines(), row) != null) { + actions.add(RefactorPopup.Action.EXTRACT_TO_FILE); + } + RouteRefactorings.Value uri = RouteRefactorings.uri(dsl, rawLine); + if (uri != null) { + actions.add(RefactorPopup.Action.REPLACE_URI); + } + if (RouteRefactorings.isExtractable(RouteRefactorings.valueAt(dsl, rawLine, editState.cursorCol()))) { + actions.add(RefactorPopup.Action.EXTRACT_TO_PROPERTY); + } + if (actions.isEmpty()) { + notifySave("Nothing to refactor here: put the cursor on an endpoint URI, a value" + + ("xml".equals(dsl) ? " or the start tag of a step" : ""), + false); + return; + } + refactorPopup = new RefactorPopup(); + refactorPopup.open(actions, uri != null ? uri.text() : null); + } + + private void applyRouteRefactoring(RefactorPopup.Request req, int row, String rawLine) { + String dsl = uriCompletion; + switch (req.action()) { + case REPLACE_URI -> { + RouteRefactorings.Value uri = RouteRefactorings.uri(dsl, rawLine); + if (uri != null) { + setLine(row, RouteRefactorings.replace(dsl, rawLine, uri, req.value())); + notifySave("Replaced URI with: " + req.value(), false); + } + } + case EXTRACT_TO_PROPERTY -> { + RouteRefactorings.Value v = RouteRefactorings.valueAt(dsl, rawLine, editState.cursorCol()); + if (!RouteRefactorings.isExtractable(v) || req.value() == null || req.value().isBlank()) { + return; + } + String key = req.value().strip(); + setLine(row, RouteRefactorings.replace(dsl, rawLine, v, "{{" + key + "}}")); + if (editableFile != null) { + Path props = RouteRefactorings.propertiesFile(editableFile); + try { + Files.createDirectories(props.getParent()); + Files.writeString(props, key + "=" + v.text() + "\n", StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException e) { + notifySave("Warning: could not write " + props.getFileName() + ": " + e.getMessage(), true); + return; + } + } + notifySave("Extracted to property: " + key, false); + } + case EXTRACT_TO_FILE -> applyXmlExtractToFile(row, req.value()); + } + } + + /** The line replaced in the edited text, the cursor kept on it. */ + private void setLine(int row, String newLine) { + recordEditChange(); + List<String> lines = editLines(); + lines.set(row, newLine); + editState.setText(YamlBlockEditor.fromLines(lines)); + SourceEditorNavigation.positionCursor(editState, row, countLeadingSpaces(newLine)); + } + + /** The XML step block of the row moved to a new route (from direct:name) in name.camel.xml, a to in its place. */ + private void applyXmlExtractToFile(int row, String name) { + if (editableFile == null) { + notifySave("Cannot extract: file is not writable", true); + return; + } + name = sanitizeFileName(name); + if (name.isEmpty()) { + notifySave("Cannot extract: invalid file name", true); + return; + } + List<String> lines = editLines(); + RouteRefactorings.Block block = RouteRefactorings.xmlStep(lines, row); + if (block == null) { + return; + } + List<String> blockLines = new ArrayList<>(lines.subList(block.start(), block.end() + 1)); + String newFileName = name + ".camel.xml"; + Path newFile = editableFile.getParent().resolve(newFileName); + String route = RouteRefactorings.xmlRoute(name, blockLines, block.indent()); + String newContent; + boolean existed = Files.exists(newFile); + try { + if (existed) { + newContent = RouteRefactorings.addXmlRoute(Files.readString(newFile, StandardCharsets.UTF_8), route); + if (newContent == null) { + notifySave(newFileName + " exists and has no </routes> to add the route to", true); + return; + } + } else { + newContent = RouteRefactorings.xmlRouteFile(name, blockLines, block.indent()); + } + } catch (IOException e) { + notifySave("Failed to read " + newFileName + ": " + e.getMessage(), true); + return; + } + recordEditChange(); + editState.setText(YamlBlockEditor.fromLines(RouteRefactorings.replaceWithTo(lines, block, name))); + SourceEditorNavigation.positionCursor(editState, block.start(), block.indent()); + // saved at once, so the route index of the jump links sees both files (as the YAML extraction does) + try { + Files.writeString(editableFile, editState.text(), StandardCharsets.UTF_8); + dirty = false; + originalEditText = editState.text(); + lineStatuses = null; + Files.writeString(newFile, newContent, StandardCharsets.UTF_8); + } catch (IOException e) { + notifySave("Failed to write " + newFileName + ": " + e.getMessage(), true); + return; + } + if (!existed && onFileCreated != null) { + onFileCreated.run(); + } + notifySave(existed ? "Added route to " + newFileName : "Extracted to " + newFileName, false); + } + private void applyRefactoring(RefactorPopup.Request req) { int row = editState.cursorRow(); if (row < 0 || row >= editState.lineCount()) { return; } String rawLine = editState.getLine(row); + if (!isCamelYamlFile()) { + applyRouteRefactoring(req, row, rawLine); + return; + } switch (req.action()) { case EXTRACT_TO_FILE -> applyExtractToFile(row, req.value()); case REPLACE_URI -> applyReplaceUri(row, rawLine, req.value()); 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 d47e8417cb24..2427517cebfb 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 @@ -17,7 +17,9 @@ explorer on the left and a source viewer on the right. - **Esc** — cancel edit (in edit mode) or close viewer - **Ctrl+S** — save file and continue editing (Camel dev mode auto-reloads) - **F5** — save file and close editor (in edit mode) -- **Ctrl+R** — open refactoring menu in edit mode (YAML files only; choose an action for the current line) +- **Ctrl+R** — open refactoring menu in edit mode, for the current line: replace + the endpoint URI, extract the value at the cursor to a property, extract a step + to a new route file (YAML and XML) - **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, diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactoringsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactoringsTest.java new file mode 100644 index 000000000000..993df7227ddd --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactoringsTest.java @@ -0,0 +1,189 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The refactorings of the source editor (Ctrl+R) for Java and XML routes (CAMEL-25256). + */ +class RouteRefactoringsTest { + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + Theme.resetForTesting(); + } + + @Test + void theEndpointUriOfALine() { + String java = " .to(\"kafka:orders?brokers=a:9092\")"; + RouteRefactorings.Value uri = RouteRefactorings.uri("java", java); + assertThat(uri.text()).isEqualTo("kafka:orders?brokers=a:9092"); + assertThat(RouteRefactorings.replace("java", java, uri, "seda:orders")) + .isEqualTo(" .to(\"seda:orders\")"); + + String xml = " <to id=\"x\" uri=\"kafka:orders?a=1&b=2\"/>"; + uri = RouteRefactorings.uri("xml", xml); + assertThat(uri.text()).isEqualTo("kafka:orders?a=1&b=2"); + assertThat(RouteRefactorings.replace("xml", xml, uri, "seda:x?a=1&b=2")) + .isEqualTo(" <to id=\"x\" uri=\"seda:x?a=1&b=2\"/>"); + + assertThat(RouteRefactorings.uri("java", " .log(\"Hello\")")).isNull(); + assertThat(RouteRefactorings.uri("xml", " <log message=\"uri\"/>")).isNull(); + } + + @Test + void theValueAtTheCursor() { + String java = " .setHeader(\"region\", constant(\"EU \\\"west\\\"\"))"; + RouteRefactorings.Value v = RouteRefactorings.valueAt("java", java, java.indexOf("EU")); + assertThat(v.text()).isEqualTo("EU \"west\""); + assertThat(RouteRefactorings.valueAt("java", java, java.indexOf("region")).text()).isEqualTo("region"); + // not in a string, or in a comment + assertThat(RouteRefactorings.valueAt("java", java, java.indexOf("constant"))).isNull(); + assertThat(RouteRefactorings.valueAt("java", " // .log(\"x\")", 12)).isNull(); + + String xml = " <log message=\"Got ${body}\" loggingLevel=\"WARN\"/>"; + assertThat(RouteRefactorings.valueAt("xml", xml, xml.indexOf("Got")).text()).isEqualTo("Got ${body}"); + // on the attribute name too + assertThat(RouteRefactorings.valueAt("xml", xml, xml.indexOf("loggingLevel")).text()).isEqualTo("WARN"); + + assertThat(RouteRefactorings.isExtractable(new RouteRefactorings.Value(0, 0, "{{x}}"))).isFalse(); + assertThat(RouteRefactorings.isExtractable(new RouteRefactorings.Value(0, 0, " "))).isFalse(); + } + + @Test + void thePropertiesFileOfARouteFile() { + assertThat(RouteRefactorings.propertiesFile(Path.of("/p/src/main/java/com/acme/MyRoute.java"))) + .isEqualTo(Path.of("/p/src/main/resources/application.properties")); + assertThat(RouteRefactorings.propertiesFile(Path.of("/p/routes/orders.camel.xml"))) + .isEqualTo(Path.of("/p/routes/application.properties")); + } + + private static final List<String> XML = List.of( + "<routes xmlns=\"http://camel.apache.org/schema/xml-io\">", + " <route id=\"orders\">", + " <from uri=\"kafka:orders\"/>", + " <split>", + " <simple>${body}</simple>", + " <to uri=\"direct:item\"/>", + " </split>", + " <log message=\"done\"/>", + " </route>", + "</routes>"); + + @Test + void anXmlStepBlock() { + RouteRefactorings.Block split = RouteRefactorings.xmlStep(XML, 3); + assertThat(split).isEqualTo(new RouteRefactorings.Block(3, 6, "split", 8)); + assertThat(RouteRefactorings.xmlStep(XML, 7)).isEqualTo(new RouteRefactorings.Block(7, 7, "log", 8)); + // the route, its from, an expression, and outside a route are no steps to extract + assertThat(RouteRefactorings.xmlStep(XML, 1)).isNull(); + assertThat(RouteRefactorings.xmlStep(XML, 2)).isNull(); + assertThat(RouteRefactorings.xmlStep(XML, 4)).isNull(); + assertThat(RouteRefactorings.xmlStep(XML, 0)).isNull(); + + List<String> replaced = RouteRefactorings.replaceWithTo(XML, split, "items"); + assertThat(replaced.get(3)).isEqualTo(" <to uri=\"direct:items\"/>"); + assertThat(replaced.get(4)).isEqualTo(" <log message=\"done\"/>"); + + String file = RouteRefactorings.xmlRouteFile("items", XML.subList(3, 7), 8); + assertThat(file).contains("<route id=\"items\">", " <from uri=\"direct:items\"/>", + " <split>", " <simple>${body}</simple>", " </split>"); + assertThat(RouteRefactorings.addXmlRoute(file, " <route id=\"more\"/>\n")).endsWith( + " <route id=\"more\"/>\n</routes>\n"); + } + + @Test + void extractAJavaValueToAProperty() throws Exception { + Path dir = Files.createDirectories(tempDir.resolve("src/main/java/com/acme")); + SourceViewer viewer = viewer(dir.resolve("MyRoute.java"), """ + public class MyRoute extends RouteBuilder { + public void configure() { + from("timer:tick") + .log("Hello Camel"); + } + } + """, "java"); + cursorAt(viewer, 3, "Camel\");"); + refactor(viewer, "app.greeting"); + assertThat(line(viewer, 3)).isEqualTo(" .log(\"{{app.greeting}}\");"); + assertThat(Files.readString(tempDir.resolve("src/main/resources/application.properties"))) + .isEqualTo("app.greeting=Hello Camel\n"); + } + + @Test + void extractAnXmlStepToANewFile() throws Exception { + Path file = tempDir.resolve("orders.camel.xml"); + SourceViewer viewer = viewer(file, String.join("\n", XML) + "\n", "xml"); + cursorAt(viewer, 3, "<split>"); + // the menu: extract to new file first + refactor(viewer, "items"); + assertThat(line(viewer, 3)).isEqualTo(" <to uri=\"direct:items\"/>"); + assertThat(Files.readString(file)).contains("<to uri=\"direct:items\"/>").doesNotContain("<split>"); + assertThat(Files.readString(tempDir.resolve("items.camel.xml"))).contains("<from uri=\"direct:items\"/>", + "<split>", "<to uri=\"direct:item\"/>"); + } + + private SourceViewer viewer(Path file, String src, String dsl) throws Exception { + Files.writeString(file, src, StandardCharsets.UTF_8); + SourceViewer viewer = new SourceViewer(); + viewer.setUriCompletion(dsl); + viewer.loadFile(file); + viewer.enterEditMode(); + return viewer; + } + + /** Ctrl+R, Enter on the first refactoring, the value typed, Enter. */ + private static void refactor(SourceViewer viewer, String value) { + viewer.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.CTRL)); + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + for (char ch : value.toCharArray()) { + viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE)); + } + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + } + + /** Puts the cursor on the line, before the given text at its end. */ + private static void cursorAt(SourceViewer viewer, int row, String tail) { + for (int i = 0; i < row; i++) { + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE)); + } + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE)); + for (int i = 0; i < tail.length(); i++) { + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE)); + } + } + + private static String line(SourceViewer viewer, int row) { + return viewer.editText().split("\n")[row]; + } +}
