This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25219-quick-doc in repository https://gitbox.apache.org/repos/asf/camel.git
commit c2307b5c7ec9bffd1320b2d486a8d69493425d63 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 21:57:30 2026 +0200 CAMEL-25219: camel-jbang - the quick doc shows the simple function the cursor is on In edit mode the quick doc panel follows the cursor within a simple expression, in YAML, Java and XML routes: - on a function: what it is, an example and its parameters, from the catalog; the written form is mapped back to the catalog function (${date:now} is date(command), overloads by their number of arguments); - on ${header.x}: the header too, set in the file or documented by a component (a component header the file uses now keeps the catalog description); - on an operator after a function: the operator and its syntax. Outside edit mode the selected line lists the functions it uses. Help, doc page and a screenshot updated. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../jbang/camel-tui-source-simple-quick-doc.png | Bin 0 -> 274197 bytes .../ROOT/pages/camel-jbang-tui-source-editor.adoc | 8 + .../core/commands/tui/SimpleCompletionContext.java | 49 ++++ .../jbang/core/commands/tui/SimpleCompletions.java | 9 +- .../jbang/core/commands/tui/SimpleQuickDoc.java | 270 +++++++++++++++++++++ .../jbang/core/commands/tui/SourceEditAssist.java | 5 + .../dsl/jbang/core/commands/tui/SourceTab.java | 2 + .../dsl/jbang/core/commands/tui/SourceViewer.java | 43 +++- .../src/main/resources/tui/help/source.md | 3 +- .../core/commands/tui/SimpleQuickDocTest.java | 124 ++++++++++ 10 files changed, 509 insertions(+), 4 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-quick-doc.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-quick-doc.png new file mode 100644 index 000000000000..6906c1a3244a Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-quick-doc.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 a775c0c57404..7593528cf780 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 @@ -154,6 +154,14 @@ project declares it (`@BindToRegistry`, `@Named`, `@Component`, `@Bean`, or the image::jbang/camel-tui-source-bean-jump.png[Where the bean of a line is declared, with a jump link to it] +In a xref:components:languages:simple-language.adoc[Simple] expression, the panel follows the cursor within the line: on +a function it shows what the function is, an example and its parameters; on `+${header.priority}+` the header too (set +in this file, or the description of a component header such as `CamelKafkaKey`); on an operator after a function, such +as `contains`, the operator and its syntax. Elsewhere on the line it shows the documentation of the line as usual, and +outside edit mode the line lists the functions it uses. + +image::jbang/camel-tui-source-simple-quick-doc.png[The documentation of the date function the cursor is on] + == Navigation * *Jump links* -- a line that sends to another route, such as `to("seda:shipping")`, shows `↵ shipping`. Press *Enter* diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java index c808e6cb9482..21b1768896d6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java @@ -107,6 +107,55 @@ record SimpleCompletionContext(Kind kind, String prefix, String closing, List<St return new SimpleCompletionContext(Kind.OPERATOR, m.group(1), " ", owners(lines, row, firstOpen)); } + /** + * A function of a simple expression on a line: the text between ${ and } (to the end of the line when it is not + * closed yet), nested functions included. + * + * @param text the text of the function, such as date:now-24h or header.priority + * @param start where the text starts in the line, after the ${ + */ + record Function(String text, int start) { + } + + /** + * The function the cursor is on, the innermost one when they are nested (${abs(${header.price})}), the ${ and } + * included; null when the cursor is not in one. + */ + static Function functionAt(String line, int col) { + Function best = null; + for (Function f : functions(line)) { + int end = f.start() + f.text().length(); + if (col >= f.start() - 2 && col <= end && (best == null || f.start() > best.start())) { + best = f; + } + } + return best; + } + + /** The functions of a line, in the order they start. */ + static List<Function> functions(String line) { + List<Function> found = new ArrayList<>(); + List<Integer> open = new ArrayList<>(); + List<Integer> slots = new ArrayList<>(); + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == '$' && i + 1 < line.length() && line.charAt(i + 1) == '{') { + open.add(i + 2); + slots.add(found.size()); + found.add(null); + i++; + } else if (c == '}' && !open.isEmpty()) { + int start = open.remove(open.size() - 1); + found.set(slots.remove(slots.size() - 1), new Function(line.substring(start, i), start)); + } + } + // the ones still open run to the end of the line, being typed + for (int k = 0; k < open.size(); k++) { + found.set(slots.get(k), new Function(line.substring(open.get(k)), open.get(k))); + } + return found; + } + /** The words before the expression that starts at col of row, nearest first. */ private static List<String> owners(List<String> lines, int row, int col) { List<String> owners = new ArrayList<>(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java index b43640d1d7fc..afd772196ecd 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java @@ -224,9 +224,14 @@ final class SimpleCompletions { continue; } for (ComponentModel.EndpointHeaderModel h : model.getEndpointHeaders()) { - items.putIfAbsent(h.getName(), new AutocompletePopup.CompletionItem( + AutocompletePopup.CompletionItem item = new AutocompletePopup.CompletionItem( h.getName(), h.getDescription(), h.getJavaType(), h.getDefaultValue(), - h.isDeprecated(), h.getDeprecationNote(), scheme, false)); + h.isDeprecated(), h.getDeprecationNote(), scheme, false); + // one the file uses keeps its place first, with the documentation of the component + AutocompletePopup.CompletionItem known = items.get(h.getName()); + if (known == null || known.group() == null) { + items.put(h.getName(), item); + } } } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleQuickDoc.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleQuickDoc.java new file mode 100644 index 000000000000..a2715e169eaa --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleQuickDoc.java @@ -0,0 +1,270 @@ +/* + * 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.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.tooling.model.LanguageModel; + +/** + * The quick doc of the simple function the cursor is on (CAMEL-25219): what it is, an example and its parameters, from + * the catalog; for ${header.x} the header too, and for an operator after a function the operator. Read from the line + * alone, as the completion is, so it works on a line being typed. Drawn on every render, so the catalog's simple model + * and the map from the written form of a function to it are kept per catalog. + */ +final class SimpleQuickDoc { + + static final String TITLE = "Simple"; + + // the simple model of the last catalog and its functions by how they are written: body, header., date:, random( + private static volatile Model model; + + private record Model( + CamelCatalog catalog, LanguageModel simple, Map<String, List<LanguageModel.LanguageFunctionModel>> written) { + } + + private SimpleQuickDoc() { + } + + /** + * The doc of what the cursor is on: col of row; col -1 (the view, no cursor) gives the functions of the line in one + * entry. + */ + static List<SourceViewer.DocEntry> at(CamelCatalog catalog, List<String> lines, int row, int col) { + if (catalog == null || lines == null || row < 0 || row >= lines.size()) { + return List.of(); + } + String line = lines.get(row); + if (line.indexOf("${") < 0) { + return List.of(); + } + Model m = model(catalog); + if (m == null) { + return List.of(); + } + if (col < 0) { + return summary(m, line); + } + SimpleCompletionContext.Function f = SimpleCompletionContext.functionAt(line, col); + if (f == null) { + return operatorAt(m, line, col); + } + LanguageModel.LanguageFunctionModel fn = resolve(m, f.text()); + if (fn == null) { + return List.of(); + } + List<SourceViewer.DocEntry> entries = new ArrayList<>(); + String insert = SimpleCompletions.insertOf(fn); + String name = SimpleCompletions.displayOf(fn.getName(), insert); + String head = name + (fn.getJavaType() != null ? " — " + simpleType(fn.getJavaType()) : "") + " — " + + firstSentence(fn.getDescription()); + entries.add(new SourceViewer.DocEntry(head, fn.isDeprecated(), TITLE)); + SourceViewer.DocEntry named = nameDoc(catalog, lines, insert, f.text()); + if (named != null) { + entries.add(named); + } + if (!fn.getExamples().isEmpty()) { + entries.add(new SourceViewer.DocEntry("Example: " + fn.getExamples().get(0), false, TITLE)); + } + if (!fn.getParams().isEmpty()) { + List<String> params = new ArrayList<>(); + for (LanguageModel.FunctionParamModel p : fn.getParams()) { + params.add(p.getName() + (p.isRequired() ? " (required)" : "") + + (p.getDescription() != null ? ": " + firstSentence(p.getDescription()) : "")); + } + entries.add(new SourceViewer.DocEntry(String.join("; ", params), false, TITLE)); + } + return entries; + } + + /** The function a written one is: the longest written form it starts with, by its number of arguments. */ + static LanguageModel.LanguageFunctionModel resolve(CamelCatalog catalog, String text) { + Model m = model(catalog); + return m != null ? resolve(m, text) : null; + } + + private static LanguageModel.LanguageFunctionModel resolve(Model m, String text) { + String t = text.strip(); + List<LanguageModel.LanguageFunctionModel> candidates = m.written().get(t); + if (candidates == null) { + String best = null; + for (String w : m.written().keySet()) { + boolean stem = w.endsWith(".") || w.endsWith(":") || w.endsWith("(") || w.endsWith("["); + if (stem && t.startsWith(w) && (best == null || w.length() > best.length())) { + best = w; + } + } + if (best == null) { + // a function used with OGNL after it: ${body.length}, ${exception.message.trim} + int dot = t.indexOf('.'); + candidates = dot > 0 ? m.written().get(t.substring(0, dot)) : null; + } else { + candidates = m.written().get(best); + } + } + if (candidates == null || candidates.isEmpty()) { + return null; + } + if (candidates.size() > 1) { + int args = arguments(t); + for (LanguageModel.LanguageFunctionModel c : candidates) { + if (c.getParams().size() == args) { + return c; + } + } + } + return candidates.get(0); + } + + /** The number of arguments given in the (...) of a written function, nested ones not counted. */ + private static int arguments(String text) { + int open = text.indexOf('('); + if (open < 0) { + return 0; + } + int depth = 0; + int args = 1; + boolean quoted = false; + for (int i = open + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\'') { + quoted = !quoted; + } else if (quoted) { + continue; + } else if (c == '(' || c == '{') { + depth++; + } else if ((c == ')' || c == '}') && depth > 0) { + depth--; + } else if (c == ')') { + break; + } else if (c == ',' && depth == 0) { + args++; + } + } + return args; + } + + /** The header, exchange property or variable after header. exchangeProperty. variable. */ + private static SourceViewer.DocEntry nameDoc(CamelCatalog catalog, List<String> lines, String insert, String text) { + String name = text.strip().substring(Math.min(insert.length(), text.strip().length())); + if (name.isEmpty()) { + return null; + } + List<AutocompletePopup.CompletionItem> known = switch (insert) { + case "header." -> SimpleCompletions.names(catalog, lines, "Header", "header", true); + case "exchangeProperty." -> SimpleCompletions.names(catalog, lines, "Property", "exchangeProperty", false); + case "variable." -> SimpleCompletions.names(catalog, lines, "Variable", "variable", false); + default -> List.of(); + }; + for (AutocompletePopup.CompletionItem item : known) { + if (item.key().equals(name)) { + String where = item.group() != null ? item.group() + " — " : ""; + return new SourceViewer.DocEntry(name + " — " + where + firstSentence(item.description()), false, TITLE); + } + } + return null; + } + + /** The operator the cursor is on, after a function: ${body} contai|ns 'x'. */ + private static List<SourceViewer.DocEntry> operatorAt(Model m, String line, int col) { + int from = col; + while (from > 0 && !Character.isWhitespace(line.charAt(from - 1))) { + from--; + } + int to = col; + while (to < line.length() && !Character.isWhitespace(line.charAt(to))) { + to++; + } + if (from >= to || !line.substring(0, from).stripTrailing().endsWith("}")) { + return List.of(); + } + String word = line.substring(from, to); + for (LanguageModel.LanguageOperatorModel op : m.simple().getOperators()) { + if (op.getName().equals(word)) { + List<SourceViewer.DocEntry> entries = new ArrayList<>(); + entries.add(new SourceViewer.DocEntry( + op.getName() + " — " + op.getDisplayName() + " — " + firstSentence(op.getDescription()), + op.isDeprecated(), TITLE)); + if (op.getOperatorSyntax() != null) { + entries.add(new SourceViewer.DocEntry("Syntax: " + op.getOperatorSyntax(), false, TITLE)); + } + if (!op.getExamples().isEmpty()) { + entries.add(new SourceViewer.DocEntry("Example: " + op.getExamples().get(0), false, TITLE)); + } + return entries; + } + } + return List.of(); + } + + /** The functions a line uses, in one entry: date:command, header.name. */ + private static List<SourceViewer.DocEntry> summary(Model m, String line) { + Set<String> names = new LinkedHashSet<>(); + for (SimpleCompletionContext.Function f : SimpleCompletionContext.functions(line)) { + LanguageModel.LanguageFunctionModel fn = resolve(m, f.text()); + if (fn != null) { + names.add(SimpleCompletions.displayOf(fn.getName(), SimpleCompletions.insertOf(fn))); + } + } + if (names.isEmpty()) { + return List.of(); + } + return List.of(new SourceViewer.DocEntry("Simple functions: " + String.join(", ", names), false, TITLE)); + } + + private static Model model(CamelCatalog catalog) { + Model m = model; + if (m == null || m.catalog() != catalog) { + LanguageModel simple = catalog.languageModel("simple"); + if (simple == null) { + return null; + } + Map<String, List<LanguageModel.LanguageFunctionModel>> written = new HashMap<>(); + for (LanguageModel.LanguageFunctionModel f : simple.getFunctions()) { + String insert = SimpleCompletions.insertOf(f); + String key = insert.endsWith("}") ? insert.substring(0, insert.length() - 1) : insert; + written.computeIfAbsent(key, k -> new ArrayList<>()).add(f); + if (key.endsWith("()")) { + // uuid() is also written uuid(short) + written.computeIfAbsent(key.substring(0, key.length() - 1), k -> new ArrayList<>()).add(f); + } + } + m = new Model(catalog, simple, written); + model = m; + } + return m; + } + + private static String simpleType(String type) { + int dot = type.lastIndexOf('.'); + return dot >= 0 ? type.substring(dot + 1) : type; + } + + private static String firstSentence(String text) { + if (text == null) { + return ""; + } + int dot = text.indexOf(". "); + return dot > 0 ? text.substring(0, dot + 1) : text; + } +} 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 062f631d5e11..b5afd4eb7a42 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 @@ -1011,6 +1011,11 @@ final class SourceEditAssist { return items; } + /** The quick doc of the simple function at the cursor (CAMEL-25219). */ + List<SourceViewer.DocEntry> provideSimpleQuickDoc(List<String> lines, int row, int col) { + return SimpleQuickDoc.at(validationCatalog(), lines, row, col); + } + /** The completions of the simple expression at the cursor (CAMEL-25219). */ List<AutocompletePopup.CompletionItem> provideSimpleCompletions(SimpleCompletionContext context, List<String> lines) { return SimpleCompletions.provide(validationCatalog(), context, lines); 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 05a641c5e5d1..9eff3188c3fe 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 @@ -848,6 +848,8 @@ class SourceTab extends AbstractTab { // simple expressions in every route file: YAML, Java and XML (CAMEL-25219) sourceViewer.setSimpleCompletion( routeFile || isCamelSourceFile(filePath) ? assist::provideSimpleCompletions : null); + sourceViewer.setCursorQuickDocProvider( + routeFile || isCamelSourceFile(filePath) ? assist::provideSimpleQuickDoc : null); if (isCamelSourceFile(filePath)) { sourceViewer.setQuickDocProvider(assist::provideCamelQuickDocs); sourceViewer.setDeprecatedLineScanner(null); 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 229f7d4f56f3..2c2b081f8c21 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 @@ -103,6 +103,12 @@ class SourceViewer { List<DocEntry> provideForLine(List<String> lines, int cursorRow); } + /** The quick doc of what the cursor is on within the line; col is -1 in the view, which has no cursor. */ + @FunctionalInterface + interface CursorQuickDocProvider { + List<DocEntry> provideAt(List<String> lines, int row, int col); + } + @FunctionalInterface interface PropertiesValidator { String validate(String line); @@ -217,6 +223,7 @@ class SourceViewer { private UriCompletion pendingUriCompletion; private BiFunction<SimpleCompletionContext, List<String>, List<AutocompletePopup.CompletionItem>> simpleCompletion; private SimpleCompletion pendingSimpleCompletion; + private CursorQuickDocProvider cursorQuickDocProvider; private List<String> routeProblems = List.of(); private List<String> validationErrors; private int validationErrorScroll; @@ -423,6 +430,14 @@ class SourceViewer { private record SimpleCompletion(int row, int endCol, SimpleCompletionContext context) { } + /** + * The quick doc of the simple function the cursor is on (CAMEL-25219), shown before the doc of the line in edit + * mode, and the functions of the selected line after it in the view; null for a file without routes. + */ + void setCursorQuickDocProvider(CursorQuickDocProvider provider) { + this.cursorQuickDocProvider = provider; + } + /** Opens the AI panel to fix the problem of the cursor line (Shift+F8); null when there is no AI panel. */ void setAskAi(MonitorContext.AskAi askAi) { this.askAi = askAi; @@ -442,6 +457,7 @@ class SourceViewer { routeValidator = null; uriCompletion = null; simpleCompletion = null; + cursorQuickDocProvider = null; } void reset() { @@ -484,6 +500,7 @@ class SourceViewer { routeValidator = null; uriCompletion = null; simpleCompletion = null; + cursorQuickDocProvider = null; } boolean isMarkdownMode() { @@ -2149,6 +2166,17 @@ class SourceViewer { rawLines.add(jo.getString("code") != null ? jo.getString("code") : ""); } viewDocEntries = editQuickDocProvider.provideForLine(rawLines, selectedLine); + if (cursorQuickDocProvider != null) { + List<DocEntry> used = cursorQuickDocProvider.provideAt(rawLines, selectedLine, -1); + if (used != null && !used.isEmpty()) { + List<DocEntry> merged = new ArrayList<>(); + if (viewDocEntries != null) { + merged.addAll(viewDocEntries); + } + merged.addAll(used); + viewDocEntries = merged; + } + } } } @@ -2358,7 +2386,20 @@ class SourceViewer { editorArea = new Rect(inner.left(), inner.top(), inner.width(), inner.height() - docPanelHeight); docArea = new Rect(inner.left(), inner.top() + inner.height() - docPanelHeight, inner.width(), docPanelHeight); lastVisibleLines = Math.max(1, editorArea.height()); - editDocEntries = editQuickDocProvider.provideForLine(editLines(), editState.cursorRow()); + List<String> textLines = editLines(); + editDocEntries = editQuickDocProvider.provideForLine(textLines, editState.cursorRow()); + if (cursorQuickDocProvider != null) { + List<DocEntry> here = cursorQuickDocProvider.provideAt(textLines, editState.cursorRow(), + editState.cursorCol()); + if (here != null && !here.isEmpty()) { + // the function the cursor is on is more to the point than the step of the line + List<DocEntry> merged = new ArrayList<>(here); + if (editDocEntries != null) { + merged.addAll(editDocEntries); + } + editDocEntries = merged; + } + } } int prefixWidth = plainMode ? 0 : 3; 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 4e39454073f4..6169e2386e74 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 @@ -51,7 +51,8 @@ a heat map of the route: where messages go, where they fail. - **Ctrl+K** — delete current line - **Ctrl+Left / Ctrl+Right** — word navigation - **Home** — smart home (content indent, then column 0) -- Quick documentation panel is shown at the bottom (shows doc for current line) +- Quick documentation panel is shown at the bottom (shows doc for current line; + in a simple expression, the function, header or operator the cursor is on) - **F7** — show diff of unsaved changes - **F9** — jump to next validation error - **Shift+F8** — ask the AI to fix the problem on the cursor line: the file is diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleQuickDocTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleQuickDocTest.java new file mode 100644 index 000000000000..c1ac71ff0717 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleQuickDocTest.java @@ -0,0 +1,124 @@ +/* + * 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 org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The quick doc of the simple function the cursor is on (CAMEL-25219). + */ +class SimpleQuickDocTest { + + private static CamelCatalog catalog; + + @BeforeAll + static void loadCatalog() { + catalog = new DefaultCamelCatalog(); + } + + @Test + void theFunctionTheCursorIsOn() { + String line = " .setBody(simple(\"Total ${abs(${header.price})} at ${date:now}\"))"; + int abs = line.indexOf("abs"); + int price = line.indexOf("price"); + int date = line.indexOf("date"); + + assertThat(SimpleCompletionContext.functionAt(line, abs).text()).isEqualTo("abs(${header.price})"); + // the innermost of nested ones + assertThat(SimpleCompletionContext.functionAt(line, price).text()).isEqualTo("header.price"); + assertThat(SimpleCompletionContext.functionAt(line, date).text()).isEqualTo("date:now"); + // on the ${ and on the } + assertThat(SimpleCompletionContext.functionAt(line, date - 2).text()).isEqualTo("date:now"); + assertThat(SimpleCompletionContext.functionAt(line, line.indexOf("}\"")).text()).isEqualTo("date:now"); + // between them, and one being typed + assertThat(SimpleCompletionContext.functionAt(line, line.indexOf(" at "))).isNull(); + assertThat(SimpleCompletionContext.functionAt("\"${bod", 6).text()).isEqualTo("bod"); + } + + @Test + void writtenFunctionsAreFoundInTheCatalog() { + assertThat(name("body")).isEqualTo("body"); + assertThat(name("body.length")).isEqualTo("body"); + assertThat(name("header.priority")).isEqualTo("header.name"); + assertThat(name("headerAs(foo,Integer)")).isEqualTo("headerAs(key,type)"); + assertThat(name("date:now-24h")).isEqualTo("date(command)"); + assertThat(name("bean:myBean.hello")).isEqualTo("bean(name.method)"); + assertThat(name("uuid(short)")).isEqualTo("uuid(type)"); + assertThat(name("uuid()")).isEqualTo("uuid(type)"); + // overloads by their number of arguments + assertThat(name("a2a:emit('Searching')")).isEqualTo("a2a:emit(message)"); + assertThat(name("a2a:emit(WORKING,'Searching, still')")).isEqualTo("a2a:emit(state,message)"); + assertThat(SimpleQuickDoc.resolve(catalog, "noSuchThing")).isNull(); + } + + private static String name(String written) { + return SimpleQuickDoc.resolve(catalog, written).getName(); + } + + @Test + void theDocOfAFunction() { + List<String> lines = List.of(" .setBody(simple(\"Created ${date:now-24h}\"))"); + List<String> doc = texts(SimpleQuickDoc.at(catalog, lines, 0, lines.get(0).indexOf("now"))); + assertThat(doc.get(0)).startsWith("date:command — Date — "); + assertThat(doc).anyMatch(t -> t.startsWith("Example: ${date:")); + assertThat(doc).anyMatch(t -> t.startsWith("command (required)")); + } + + @Test + void theDocOfAHeaderTheFileSets() { + List<String> lines = List.of( + "from(\"kafka:orders\")", + " .setHeader(\"priority\", constant(1))", + " .filter(simple(\"${header.priority} == 1 && ${header.CamelKafkaKey} != null\"))"); + String line = lines.get(2); + assertThat(texts(SimpleQuickDoc.at(catalog, lines, 2, line.indexOf("priority")))) + .contains("priority — Set in this file"); + assertThat(texts(SimpleQuickDoc.at(catalog, lines, 2, line.indexOf("CamelKafkaKey")))) + .anyMatch(t -> t.startsWith("CamelKafkaKey — kafka — ")); + } + + @Test + void theDocOfAnOperator() { + List<String> lines = List.of("<simple>${body} contains 'Camel'</simple>"); + List<String> doc = texts(SimpleQuickDoc.at(catalog, lines, 0, lines.get(0).indexOf("tains"))); + assertThat(doc.get(0)).startsWith("contains — Contains — "); + assertThat(doc).contains("Syntax: LHS contains RHS"); + + // a word of a log message is no operator + lines = List.of(" .log(\"Got contains\")"); + assertThat(SimpleQuickDoc.at(catalog, lines, 0, lines.get(0).indexOf("contains"))).isEmpty(); + } + + @Test + void theViewListsTheFunctionsOfTheLine() { + List<String> lines = List.of(" simple: \"${header.foo} at ${date:now:yyyyMMdd} by ${body}\""); + assertThat(texts(SimpleQuickDoc.at(catalog, lines, 0, -1))) + .containsExactly("Simple functions: header.name, date:command, body"); + assertThat(SimpleQuickDoc.at(catalog, List.of(" .to(\"log:x\")"), 0, -1)).isEmpty(); + } + + private static List<String> texts(List<SourceViewer.DocEntry> entries) { + return entries.stream().map(SourceViewer.DocEntry::text).toList(); + } +}
