This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25219 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7f2b85ca808b17618719aa1d7a1af6eac85e807c Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 21:48:56 2026 +0200 CAMEL-25219: camel-jbang - Tab completes simple expressions in the source editor In YAML, Java and XML routes alike, read from the line so it works while the file does not parse: - after ${ the functions of the simple language, from the catalog, inserted the way their examples write them (${body}, ${date:, ${random(), with their parameters and examples in the details panel; - after ${header. (exchangeProperty., variable.) the names the file sets or reads, then the headers of the components it uses; - after a function and a space the operators: comparisons and && || where the EIP takes a predicate (asPredicate in the catalog), ~> and ?: elsewhere. 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-completion.png | Bin 0 -> 370850 bytes .../ROOT/pages/camel-jbang-tui-source-editor.adoc | 10 +- .../jbang/core/commands/tui/AutocompletePopup.java | 34 ++- .../core/commands/tui/SimpleCompletionContext.java | 141 ++++++++++ .../jbang/core/commands/tui/SimpleCompletions.java | 286 +++++++++++++++++++ .../jbang/core/commands/tui/SourceEditAssist.java | 5 + .../dsl/jbang/core/commands/tui/SourceTab.java | 3 + .../dsl/jbang/core/commands/tui/SourceViewer.java | 115 ++++++++ .../src/main/resources/tui/help/source.md | 10 + .../core/commands/tui/SimpleCompletionTest.java | 307 +++++++++++++++++++++ 10 files changed, 906 insertions(+), 5 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-completion.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-completion.png new file mode 100644 index 000000000000..913c6d966f9b Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-completion.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 cb1ec9113de0..a775c0c57404 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 @@ -13,7 +13,7 @@ routes. as a file opens and while you type, in YAML, Java and XML routes; a YAML or XML file with problems is not saved. * <<_quick_fixes,*Quick fixes*>> -- *Shift+F9* applies the fix a problem names, such as an option typo. * <<_fix_with_ai,*Fix with AI*>> -- *Shift+F8* asks the AI to fix the problem of a line; you confirm every change. -* <<_completion,*Completion*>> -- *Tab* completes components, endpoint options and their values. +* <<_completion,*Completion*>> -- *Tab* completes components, endpoint options and their values, and simple expressions. * <<_quick_documentation,*Quick documentation*>> -- the documentation of the line the cursor is on, at the bottom, with the values of its property placeholders and where its bean is declared. * <<_navigation,*Navigation*>> -- jump between routes and to beans, see where an endpoint is used with *u*, find any node @@ -122,10 +122,18 @@ What can be completed: given), and the value of an option after `=`. * *YAML routes* -- EIP names, component names (only the components that can consume on a `from`, and produce on a `to`), endpoint options in a `parameters:` block (the required ones first), EIP options, and option values. +* *Simple expressions*, in YAML, Java and XML routes alike -- after `+${+` the functions of the + xref:components:languages:simple-language.adoc[Simple] language, with their parameters and examples in the details + panel, inserted the way they are written (`+${body}+`, `+${date:+`, `+${random(+`). After `+${header.+` (and + `exchangeProperty.`, `variable.`) the names the file sets or reads, then the headers of the components it uses. After a + function and a space the operators: comparisons, `&&` and `||` where the EIP takes a predicate (`when`, `filter`, + `validate`, `onWhen`, ...), chaining (`~>`) and a default value (`?:`) in other expressions. * *`application.properties`* -- Camel options (`+camel.main.*+`, `+camel.component.*+`, ...) and Spring Boot properties (`+server.*+`, `+spring.*+`, ...), with their values and `+{{placeholder}}+` suggestions. This works even when the project is not running. +image::jbang/camel-tui-source-simple-completion.png[After ${header. Tab lists the headers the route sets, then the ones of the Kafka component] + == Quick Documentation The panel at the bottom shows the documentation of the line the cursor is on, from the Camel catalog: the component diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java index 8de140e54531..245bcb6fcde6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java @@ -45,12 +45,26 @@ import dev.tamboui.widgets.scrollbar.ScrollbarState; class AutocompletePopup { + /** + * An entry of the popup; insert is the text that goes into the line when it differs from the key shown (a simple + * function is listed as date:command and inserted as date:), null otherwise. + */ record CompletionItem(String key, String description, String type, Object defaultValue, - boolean deprecated, String deprecationNote, String group, boolean required) { + boolean deprecated, String deprecationNote, String group, boolean required, String insert) { + + CompletionItem(String key, String description, String type, Object defaultValue, + boolean deprecated, String deprecationNote, String group, boolean required) { + this(key, description, type, defaultValue, deprecated, deprecationNote, group, required, null); + } CompletionItem(String key, String description, String type, Object defaultValue, boolean deprecated, String deprecationNote, String group) { - this(key, description, type, defaultValue, deprecated, deprecationNote, group, false); + this(key, description, type, defaultValue, deprecated, deprecationNote, group, false, null); + } + + /** The text that goes into the line. */ + String insertText() { + return insert != null ? insert : key; } } @@ -83,6 +97,7 @@ class AutocompletePopup { private CompletionItem selectedItem; private Rect popupRect; private String titlePrefix; + private boolean fullKeys; AutocompletePopup(List<CompletionItem> items, String initialPrefix, String lineKeyText) { this(items, initialPrefix, lineKeyText, false); @@ -314,7 +329,7 @@ class AutocompletePopup { Style keyStyle = ci.deprecated() ? deprecatedStyle : ci.required() ? boldStyle : normalStyle; String displayKey = key; - if (!key.startsWith("{{") && !key.endsWith(".")) { + if (!fullKeys && !key.startsWith("{{") && !key.endsWith(".")) { int lastDot = key.lastIndexOf('.'); if (lastDot >= 0) { displayKey = key.substring(lastDot + 1); @@ -435,7 +450,10 @@ class AutocompletePopup { } if (selected.description() != null) { lines.add(Line.empty()); - lines.add(Line.from(Span.styled(selected.description(), dimStyle))); + // a simple function's description is followed by its parameters and examples, each on a line + for (String part : selected.description().split("\n", -1)) { + lines.add(part.isEmpty() ? Line.empty() : Line.from(Span.styled(part, dimStyle))); + } } } @@ -499,6 +517,14 @@ class AutocompletePopup { return null; } + /** + * Lists the keys whole: header.name of the simple functions is not a property key whose group (up to its last dot) + * goes without saying. + */ + void setFullKeys(boolean fullKeys) { + this.fullKeys = fullKeys; + } + boolean isValueMode() { return valueMode; } 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 new file mode 100644 index 000000000000..c808e6cb9482 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java @@ -0,0 +1,141 @@ +/* + * 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.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The simple expression the cursor is in, read from the line alone so it works on a line being typed, which does not + * parse, and the same in YAML, Java and XML (CAMEL-25219): ${hea| is in a function, ${header.Camel| in a header name, + * ${body} | after a function where an operator goes. + * + * @param kind what goes at the cursor + * @param prefix the text before the cursor that the completion replaces + * @param closing what follows a chosen name: } after header.x, ']} after header['x + * @param owners the words before the expression, nearest first (when, filter, setBody, log...), to tell whether the + * expression is a predicate; the language words (simple, expression) are left out + */ +record SimpleCompletionContext(Kind kind, String prefix, String closing, List<String> owners) { + + enum Kind { + FUNCTION, + HEADER, + PROPERTY, + VARIABLE, + OPERATOR + } + + /** How many lines above the cursor are looked at for the EIP the expression belongs to. */ + private static final int OWNER_LINES = 8; + private static final Set<String> LANGUAGE_WORDS = Set.of("simple", "expression", "language", "camel"); + private static final Pattern NAME = Pattern.compile( + "(header|headers|in\\.header|in\\.headers|exchangeProperty|variable|variables)(\\.|\\[['\"]?)([\\w.\\-]*)"); + private static final Pattern FUNCTION = Pattern.compile("[\\w.:\\-]*"); + private static final Pattern OPERATOR = Pattern.compile("\\s+([^\\s'\"$]*)"); + private static final Pattern WORD = Pattern.compile("[A-Za-z_][\\w-]*"); + + /** The context at the cursor, or null when the cursor is not where a simple completion goes. */ + static SimpleCompletionContext at(List<String> lines, int row, int col) { + if (lines == null || row < 0 || row >= lines.size()) { + return null; + } + String line = lines.get(row); + if (col < 0 || col > line.length()) { + return null; + } + // the ${ still open at the cursor, nested ones included: ${abs(${header.pr| + List<Integer> open = new ArrayList<>(); + int firstOpen = -1; + int lastClose = -1; + for (int i = 0; i < col; i++) { + char c = line.charAt(i); + if (c == '$' && i + 1 < col && line.charAt(i + 1) == '{') { + if (firstOpen < 0) { + firstOpen = i; + } + open.add(i + 2); + i++; + } else if (c == '}' && !open.isEmpty()) { + open.remove(open.size() - 1); + lastClose = i; + } + } + if (firstOpen < 0) { + return null; + } + if (!open.isEmpty()) { + String inner = line.substring(open.get(open.size() - 1), col); + Matcher m = NAME.matcher(inner); + if (m.matches()) { + Kind kind = switch (m.group(1)) { + case "exchangeProperty" -> Kind.PROPERTY; + case "variable", "variables" -> Kind.VARIABLE; + default -> Kind.HEADER; + }; + String bracket = m.group(2); + String closing = ".".equals(bracket) ? "}" : bracket.substring(1) + "]}"; + return new SimpleCompletionContext(kind, m.group(3), closing, List.of()); + } + if (FUNCTION.matcher(inner).matches()) { + return new SimpleCompletionContext(Kind.FUNCTION, inner, "", List.of()); + } + // in the arguments of a function, which take expressions of their own + return null; + } + Matcher m = OPERATOR.matcher(line.substring(lastClose + 1, col)); + if (!m.matches()) { + return null; + } + return new SimpleCompletionContext(Kind.OPERATOR, m.group(1), " ", owners(lines, row, firstOpen)); + } + + /** 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<>(); + for (int r = row; r >= 0 && r > row - OWNER_LINES; r--) { + String text = r == row ? lines.get(r).substring(0, col) : lines.get(r); + Matcher m = WORD.matcher(text); + List<String> words = new ArrayList<>(); + while (m.find()) { + if (!LANGUAGE_WORDS.contains(m.group())) { + words.add(m.group()); + } + } + for (int i = words.size() - 1; i >= 0; i--) { + owners.add(words.get(i)); + } + } + return owners; + } + + /** Whether the expression is a predicate: the nearest EIP word before it takes one. */ + boolean isPredicate(Set<String> predicateWords, Set<String> eipWords) { + for (String w : owners) { + if (predicateWords.contains(w)) { + return true; + } + if (eipWords.contains(w)) { + return false; + } + } + return false; + } +} 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 new file mode 100644 index 000000000000..b43640d1d7fc --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java @@ -0,0 +1,286 @@ +/* + * 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.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.tooling.model.BaseOptionModel; +import org.apache.camel.tooling.model.ComponentModel; +import org.apache.camel.tooling.model.EipModel; +import org.apache.camel.tooling.model.LanguageModel; + +/** + * The Tab completions of simple expressions in the Source editor, for YAML, Java and XML routes alike (CAMEL-25219): + * the functions of the simple language after ${, the header, exchange property and variable names after ${header., and + * the operators after a function. All from the catalog's simple language model, so they follow the Camel version of the + * project. + */ +final class SimpleCompletions { + + /** The operators after a function of a predicate (when, filter...): comparisons, and joining predicates. */ + private static final Set<String> PREDICATE_OPERATOR_KINDS = Set.of("binary", "logical"); + /** The operators after a function of an expression (setBody, log...): chaining, and a default value. */ + private static final Set<String> EXPRESSION_OPERATOR_KINDS = Set.of("chain", "other"); + private static final int MAX_EXAMPLES = 3; + private static final Pattern IDENT = Pattern.compile("[A-Za-z0-9-]+"); + private static final Pattern SCHEME = Pattern.compile("(?<![\\w.+-])([a-z][a-z0-9-]*):[^\\s:]"); + + // the predicate and EIP words of the last catalog: reading them goes through every model + private static volatile Words words; + + private record Words(CamelCatalog catalog, Set<String> predicate, Set<String> eip) { + } + + private SimpleCompletions() { + } + + /** The completions at the context, given the lines of the file being edited. */ + static List<AutocompletePopup.CompletionItem> provide( + CamelCatalog catalog, SimpleCompletionContext context, List<String> lines) { + if (catalog == null || context == null) { + return List.of(); + } + LanguageModel simple = catalog.languageModel("simple"); + if (simple == null) { + return List.of(); + } + return switch (context.kind()) { + case FUNCTION -> functions(simple); + case OPERATOR -> { + Words w = words(catalog); + yield operators(simple, context.isPredicate(w.predicate(), w.eip())); + } + case HEADER -> names(catalog, lines, "Header", "header", true); + case PROPERTY -> names(catalog, lines, "Property", "exchangeProperty", false); + case VARIABLE -> names(catalog, lines, "Variable", "variable", false); + }; + } + + static List<AutocompletePopup.CompletionItem> functions(LanguageModel simple) { + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (LanguageModel.LanguageFunctionModel f : simple.getFunctions()) { + String insert = insertOf(f); + items.add(new AutocompletePopup.CompletionItem( + displayOf(f.getName(), insert), describe(f), f.getJavaType(), null, + f.isDeprecated(), f.getDeprecationNote(), f.getGroup(), false, insert)); + } + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + /** + * What goes into the line for a function: the whole of it with its } when it takes nothing (body}), else the start + * of it in the syntax its examples use, up to where the user goes on (header. date: substring( bean:). The names of + * the catalog are not that syntax: date(command) is written ${date:now}. + */ + static String insertOf(LanguageModel.LanguageFunctionModel f) { + String key = f.getName(); + Matcher m = IDENT.matcher(key); + if (!m.lookingAt()) { + return key; + } + String ident = m.group(); + if (f.getParams().isEmpty() && key.indexOf('(') < 0) { + return key + "}"; + } + boolean optional = f.getParams().stream().noneMatch(LanguageModel.FunctionParamModel::isRequired); + String stem = null; + for (String example : f.getExamples()) { + if (!example.startsWith("${" + ident)) { + continue; + } + String rest = example.substring(2 + ident.length()); + if (optional && (rest.startsWith("}") || rest.startsWith("()}"))) { + // used as is: ${uuid()}, ${messageHistory()} + return ident + rest.substring(0, rest.indexOf('}') + 1); + } + if (stem == null && !rest.isEmpty() && "(:.[".indexOf(rest.charAt(0)) >= 0) { + stem = ident + rest.charAt(0); + } + } + if (stem != null) { + return stem; + } + return key.length() > ident.length() ? key.substring(0, ident.length() + 1) : key + "}"; + } + + /** The name a function is listed under: the catalog name in the syntax it is inserted with (date:command). */ + static String displayOf(String key, String insert) { + if (insert.endsWith("}")) { + return key; + } + char delimiter = insert.charAt(insert.length() - 1); + int at = insert.length() - 1; + if (key.length() <= at || key.charAt(at) == delimiter) { + return key; + } + String rest = key.substring(at + 1); + if (key.charAt(at) == '(' && rest.endsWith(")")) { + rest = rest.substring(0, rest.length() - 1); + } + return insert + rest; + } + + private static String describe(LanguageModel.LanguageFunctionModel f) { + StringBuilder sb = new StringBuilder(f.getDescription() != null ? f.getDescription() : ""); + if (!f.getParams().isEmpty()) { + sb.append("\n\nParameters:"); + for (LanguageModel.FunctionParamModel p : f.getParams()) { + sb.append("\n ").append(p.getName()); + if (p.isRequired()) { + sb.append(" (required)"); + } else if (p.getDefaultValue() != null) { + sb.append(" (default ").append(p.getDefaultValue()).append(')'); + } + if (p.getDescription() != null) { + sb.append(": ").append(p.getDescription()); + } + } + } + appendExamples(sb, f.getExamples()); + return sb.toString(); + } + + private static void appendExamples(StringBuilder sb, List<String> examples) { + if (!examples.isEmpty()) { + sb.append("\n\nExamples:"); + examples.stream().limit(MAX_EXAMPLES).forEach(e -> sb.append("\n ").append(e)); + } + } + + static List<AutocompletePopup.CompletionItem> operators(LanguageModel simple, boolean predicate) { + Set<String> kinds = predicate ? PREDICATE_OPERATOR_KINDS : EXPRESSION_OPERATOR_KINDS; + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (LanguageModel.LanguageOperatorModel op : simple.getOperators()) { + // ! goes before a function, not after one + if (!kinds.contains(op.getOperatorKind()) || "!".equals(op.getName())) { + continue; + } + StringBuilder sb = new StringBuilder(op.getDescription() != null ? op.getDescription() : ""); + if (op.getOperatorSyntax() != null) { + sb.append("\n\nSyntax: ").append(op.getOperatorSyntax()); + } + appendExamples(sb, op.getExamples()); + items.add(new AutocompletePopup.CompletionItem( + op.getName(), sb.toString(), null, null, op.isDeprecated(), op.getDeprecationNote(), + op.getDisplayName(), false)); + } + return items; + } + + /** + * The header (exchange property, variable) names of the file: the ones it sets or reads, then for headers the ones + * of the components it uses, from the catalog. + */ + static List<AutocompletePopup.CompletionItem> names( + CamelCatalog catalog, List<String> lines, String setWord, String readWord, boolean componentHeaders) { + String text = String.join("\n", lines); + Map<String, AutocompletePopup.CompletionItem> items = new LinkedHashMap<>(); + // Java: setHeader("foo", ...; YAML and XML: setHeader with a name: foo or name="foo" below or after it + for (String verb : List.of("set" + setWord, "remove" + setWord)) { + addNames(items, text, Pattern.compile("\\b" + verb + "\\s*\\(\\s*\"([^\"]+)\""), "Set in this file"); + addNames(items, text, + Pattern.compile( + "\\b" + verb + "\\b[^(]{0,300}?\\bname\\s*[:=]\\s*['\"]?([\\w.\\-]+)"), + "Set in this file"); + } + addNames(items, text, Pattern.compile("\\$\\{(?:in\\.)?" + readWord + "s?(?:\\.|\\[['\"]?)([\\w\\-]+)"), + "Used in this file"); + addNames(items, text, Pattern.compile("\\b" + readWord + "\\s*\\(\\s*\"([^\"]+)\""), "Used in this file"); + if (componentHeaders) { + Set<String> components = new HashSet<>(catalog.findComponentNames()); + Matcher m = SCHEME.matcher(text); + Set<String> seen = new HashSet<>(); + while (m.find()) { + String scheme = m.group(1); + if (!components.contains(scheme) || !seen.add(scheme)) { + continue; + } + ComponentModel model = catalog.componentModel(scheme); + if (model == null) { + continue; + } + for (ComponentModel.EndpointHeaderModel h : model.getEndpointHeaders()) { + items.putIfAbsent(h.getName(), new AutocompletePopup.CompletionItem( + h.getName(), h.getDescription(), h.getJavaType(), h.getDefaultValue(), + h.isDeprecated(), h.getDeprecationNote(), scheme, false)); + } + } + } + return new ArrayList<>(items.values()); + } + + private static void addNames( + Map<String, AutocompletePopup.CompletionItem> items, String text, Pattern pattern, String description) { + Matcher m = pattern.matcher(text); + while (m.find()) { + String name = m.group(1); + items.putIfAbsent(name, new AutocompletePopup.CompletionItem( + name, description, null, null, false, null, null, false)); + } + } + + private static Words words(CamelCatalog catalog) { + Words w = words; + if (w == null || w.catalog() != catalog) { + w = new Words(catalog, predicateWords(catalog), eipWords(catalog)); + words = w; + } + return w; + } + + /** + * The words that make the expression after them a predicate: the EIPs whose expression is one (when, filter, + * validate...) and the predicate options of the others (onWhen, handled, retryWhile, completionPredicate...). + */ + static Set<String> predicateWords(CamelCatalog catalog) { + Set<String> words = new HashSet<>(); + for (String name : catalog.findModelNames()) { + EipModel model = catalog.eipModel(name); + if (model == null) { + continue; + } + for (BaseOptionModel option : model.getOptions()) { + if (option.isAsPredicate()) { + words.add("expression".equals(option.getName()) ? name : option.getName()); + } + } + } + return words; + } + + /** The EIP names: the nearest one before an expression tells what it belongs to. */ + static Set<String> eipWords(CamelCatalog catalog) { + Set<String> words = new HashSet<>(); + for (String name : catalog.findModelNames()) { + EipModel model = catalog.eipModel(name); + if (model != null && model.getLabel() != null && model.getLabel().contains("eip")) { + words.add(name); + } + } + return words; + } +} 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 34e1b800e9c6..062f631d5e11 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 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); + } + List<AutocompletePopup.CompletionItem> provideComponentNameCompletions(String role) { CamelCatalog catalog = validationCatalog(); if (catalog == null) { 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 7c53bc357ec9..05a641c5e5d1 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 @@ -845,6 +845,9 @@ class SourceTab extends AbstractTab { sourceViewer.setAskAi(ctx.askAiCallback); String name = filePath.getFileName().toString(); sourceViewer.setUriCompletion(!routeFile ? null : name.endsWith(".java") ? "java" : "xml"); + // simple expressions in every route file: YAML, Java and XML (CAMEL-25219) + sourceViewer.setSimpleCompletion( + routeFile || isCamelSourceFile(filePath) ? assist::provideSimpleCompletions : 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 2ebc6e3d5ae1..229f7d4f56f3 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 @@ -29,6 +29,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.IntConsumer; @@ -214,6 +215,8 @@ class SourceViewer { private String uriCompletion; /** The completion being chosen in a uri: its row, the column its prefix ends at, the prefix and suffix. */ private UriCompletion pendingUriCompletion; + private BiFunction<SimpleCompletionContext, List<String>, List<AutocompletePopup.CompletionItem>> simpleCompletion; + private SimpleCompletion pendingSimpleCompletion; private List<String> routeProblems = List.of(); private List<String> validationErrors; private int validationErrorScroll; @@ -407,6 +410,19 @@ class SourceViewer { private record UriCompletion(int row, int endCol, String prefix, String suffix) { } + /** + * Tab completion in the simple expressions of a YAML, Java or XML route file (CAMEL-25219): the functions after ${, + * the header names after ${header., the operators after a function. Given the context at the cursor and the lines + * of the file; null for a file without routes. + */ + void setSimpleCompletion( + BiFunction<SimpleCompletionContext, List<String>, List<AutocompletePopup.CompletionItem>> provider) { + this.simpleCompletion = provider; + } + + private record SimpleCompletion(int row, int endCol, SimpleCompletionContext context) { + } + /** 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; @@ -425,6 +441,7 @@ class SourceViewer { simpleValidator = null; routeValidator = null; uriCompletion = null; + simpleCompletion = null; } void reset() { @@ -466,6 +483,7 @@ class SourceViewer { simpleValidator = null; routeValidator = null; uriCompletion = null; + simpleCompletion = null; } boolean isMarkdownMode() { @@ -1176,6 +1194,10 @@ class SourceViewer { private void openAutocomplete() { pendingUriCompletion = null; + pendingSimpleCompletion = null; + if (openSimpleAutocomplete()) { + return; + } if (isCamelYamlFile()) { openYamlAutocomplete(); } else if (uriCompletion != null) { @@ -1246,6 +1268,95 @@ class SourceViewer { } } + /** + * The completion of the simple expression the cursor is in, read from the line alone like the uris: true when the + * cursor is in one, so no other completion is tried, also when nothing matches there. + */ + private boolean openSimpleAutocomplete() { + if (simpleCompletion == null) { + return false; + } + int row = editState.cursorRow(); + int col = editState.cursorCol(); + List<String> all = new ArrayList<>(editState.lineCount()); + for (int i = 0; i < editState.lineCount(); i++) { + all.add(editState.getLine(i)); + } + SimpleCompletionContext c = SimpleCompletionContext.at(all, row, col); + if (c == null) { + return false; + } + List<AutocompletePopup.CompletionItem> items = simpleCompletion.apply(c, all); + if (items != null && !items.isEmpty()) { + // not in value mode, which puts a popup with a long description over the line being edited + autocompletePopup = new AutocompletePopup(items, c.prefix(), c.prefix(), false); + autocompletePopup.setFullKeys(true); + autocompletePopup.setTitlePrefix(switch (c.kind()) { + case FUNCTION -> "Simple functions"; + case HEADER -> "Headers"; + case PROPERTY -> "Exchange properties"; + case VARIABLE -> "Variables"; + case OPERATOR -> "Operators"; + }); + pendingSimpleCompletion = new SimpleCompletion(row, col, c); + } + return true; + } + + /** + * Replaces the prefix the simple completion was opened on with the chosen item, and what closes it (the } of a + * function or name, the space after an operator) unless that is there already. A chosen header. or + * exchangeProperty. opens the names right away. + */ + private void insertSimpleCompletion(AutocompletePopup.CompletionItem item) { + SimpleCompletion sc = pendingSimpleCompletion; + pendingSimpleCompletion = null; + if (editState.cursorRow() != sc.row()) { + return; + } + SimpleCompletionContext c = sc.context(); + int col = editState.cursorCol(); + for (; col < sc.endCol(); col++) { + editState.moveCursorRight(); + } + for (; col > sc.endCol(); col--) { + editState.moveCursorLeft(); + } + for (int i = 0; i < c.prefix().length(); i++) { + editState.deleteBackward(); + } + // Tab in the middle of a word replaces all of it + String line = editState.getLine(sc.row()); + int from = editState.cursorCol(); + int end = from; + boolean operator = c.kind() == SimpleCompletionContext.Kind.OPERATOR; + while (end < line.length() && isSimpleWordChar(line.charAt(end), operator)) { + end++; + } + for (int i = from; i < end; i++) { + editState.deleteForward(); + } + String text = item.insertText(); + String closing = c.closing(); + if (c.kind() == SimpleCompletionContext.Kind.FUNCTION && text.endsWith("}")) { + text = text.substring(0, text.length() - 1); + closing = "}"; + } + boolean hasClosing = !closing.isEmpty() && line.startsWith(closing, end); + editState.insert(text + (hasClosing ? "" : closing)); + if (c.kind() == SimpleCompletionContext.Kind.FUNCTION && (text.endsWith(".") || text.endsWith("["))) { + openAutocomplete(); + } + } + + /** A character of the function, name or operator being completed. */ + private static boolean isSimpleWordChar(char c, boolean operator) { + if (operator) { + return !Character.isWhitespace(c) && c != '$' && c != '\'' && c != '"'; + } + return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '.' || c == ':'; + } + /** Replaces the prefix the completion was opened on with the chosen item and what follows it (: or =). */ private void insertUriCompletion(AutocompletePopup.CompletionItem item) { UriCompletion jc = pendingUriCompletion; @@ -1456,6 +1567,10 @@ class SourceViewer { insertUriCompletion(item); return; } + if (pendingSimpleCompletion != null) { + insertSimpleCompletion(item); + return; + } String currentLine = editState.getLine(editState.cursorRow()); if (isCamelYamlFile()) { insertYamlCompletion(item, valueMode, currentLine, listItem); 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 c563f8f24b66..4e39454073f4 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 @@ -100,6 +100,16 @@ Press **F4** to enter edit mode, then **Tab** for context-aware completion: `?` or `&` (`&` in XML; filtered by consumer/producer role, already given ones left out), and the value of an option after `=` +**Simple expressions (YAML, Java and XML routes):** +- After `${`, Tab lists the functions of the simple language, with their + parameters and examples; the chosen one is inserted as it is written + (`${body}`, `${date:`, `${random(`) +- After `${header.` (also `exchangeProperty.` and `variable.`), Tab lists the + names the file sets or reads, then the headers of the components it uses +- After a function and a space, Tab lists the operators: comparisons and + `&&` `||` where the EIP takes a predicate (`when`, `filter`, `validate`, + `onWhen`...), chaining (`~>`) and the default value (`?:`) elsewhere + Use **Up/Down** to navigate, **Enter** to accept, **Esc** to dismiss, and type to filter the completion list (an exact or prefix match comes first). diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionTest.java new file mode 100644 index 000000000000..e01d45e42e46 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionTest.java @@ -0,0 +1,307 @@ +/* + * 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 java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.terminal.Frame; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.tooling.model.LanguageModel; +import org.junit.jupiter.api.BeforeAll; +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; + +/** + * Tab completion in the simple expressions of YAML, Java and XML route files (CAMEL-25219). + */ +class SimpleCompletionTest { + + private static CamelCatalog catalog; + + @TempDir + Path tempDir; + + @BeforeAll + static void loadCatalog() { + catalog = new DefaultCamelCatalog(); + } + + @BeforeEach + void setUp() { + Theme.resetForTesting(); + } + + private static SimpleCompletionContext at(String line) { + return SimpleCompletionContext.at(List.of(line), 0, line.length()); + } + + @Test + void theFunctionTheCursorIsIn() { + SimpleCompletionContext c = at(" .setBody(simple(\"Hello ${hea"); + assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.FUNCTION); + assertThat(c.prefix()).isEqualTo("hea"); + + assertThat(at(" simple: \"${").prefix()).isEmpty(); + // nested in the argument of another function + assertThat(at("${abs(${bo").prefix()).isEqualTo("bo"); + // in the arguments of a function, after a closed one, with no ${ at all + assertThat(at("${substring(1, ")).isNull(); + assertThat(at("${body}")).isNull(); + assertThat(at(" .log(\"Hello")).isNull(); + } + + @Test + void theNameTheCursorIsIn() { + SimpleCompletionContext c = at("${header.Camel"); + assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.HEADER); + assertThat(c.prefix()).isEqualTo("Camel"); + assertThat(c.closing()).isEqualTo("}"); + + c = at("${headers['ord"); + assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.HEADER); + assertThat(c.prefix()).isEqualTo("ord"); + assertThat(c.closing()).isEqualTo("']}"); + + assertThat(at("${exchangeProperty.").kind()).isEqualTo(SimpleCompletionContext.Kind.PROPERTY); + assertThat(at("${variable.x").kind()).isEqualTo(SimpleCompletionContext.Kind.VARIABLE); + } + + @Test + void theOperatorAfterAFunction() { + SimpleCompletionContext c = at(" .filter(simple(\"${header.foo} con"); + assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.OPERATOR); + assertThat(c.prefix()).isEqualTo("con"); + assertThat(c.owners()).startsWith("filter"); + + // right after the } or past the string + assertThat(at("${header.foo}")).isNull(); + assertThat(at(" .log(\"${body}\" + ")).isNull(); + } + + @Test + void aPredicateIsToldByTheEipBeforeIt() { + assertThat(predicate(List.of(" .filter(simple(\"${body} "))).isTrue(); + assertThat(predicate(List.of(" .choice().when().simple(\"${body} "))).isTrue(); + assertThat(predicate(List.of(" .setBody(simple(\"${body} "))).isFalse(); + assertThat(predicate(List.of(" .log(\"${body} "))).isFalse(); + // onException options + assertThat(predicate(List.of(" onException(Exception.class).handled(simple(\"${body} "))).isTrue(); + // YAML and XML: the EIP is on a line above + assertThat(predicate(List.of("- filter:", " simple: \"${body} "))).isTrue(); + assertThat(predicate(List.of("- filter:", " expression:", " simple:", " expression: \"${body} "))) + .isTrue(); + assertThat(predicate(List.of("- log:", " message: \"${body} "))).isFalse(); + assertThat(predicate(List.of("<when>", " <simple>${body} "))).isTrue(); + assertThat(predicate(List.of("<setBody>", " <simple>${body} "))).isFalse(); + } + + private static boolean predicate(List<String> lines) { + int row = lines.size() - 1; + SimpleCompletionContext c = SimpleCompletionContext.at(lines, row, lines.get(row).length()); + List<String> ops = SimpleCompletions.provide(catalog, c, lines).stream() + .map(AutocompletePopup.CompletionItem::key).toList(); + boolean predicate = ops.contains("=="); + // the operators of an expression are never mixed with the ones of a predicate + assertThat(ops.contains("~>")).isNotEqualTo(predicate); + return predicate; + } + + @Test + void functionsAreInsertedInTheirSyntax() { + LanguageModel simple = catalog.languageModel("simple"); + Map<String, AutocompletePopup.CompletionItem> items = SimpleCompletions.functions(simple).stream() + .collect(Collectors.toMap(AutocompletePopup.CompletionItem::key, Function.identity())); + + assertThat(items.get("body").insertText()).isEqualTo("body}"); + assertThat(items.get("header.name").insertText()).isEqualTo("header."); + assertThat(items.get("random(min,max)").insertText()).isEqualTo("random("); + assertThat(items.get("uuid(type)").insertText()).isEqualTo("uuid()}"); + assertThat(items.get("properties:key:default").insertText()).isEqualTo("properties:"); + // the catalog names date(command) and bean(name.method), written ${date:now} and ${bean:foo} + assertThat(items.get("date:command").insertText()).isEqualTo("date:"); + assertThat(items.get("bean:name.method").insertText()).isEqualTo("bean:"); + assertThat(items).doesNotContainKey("date(command)"); + // the details show the parameters and examples + assertThat(items.get("random(min,max)").description()).contains("Parameters:", "min (required)", "Examples:"); + } + + @Test + void functionsAreListedWithTheirWholeName() { + AutocompletePopup popup = new AutocompletePopup( + SimpleCompletions.functions(catalog.languageModel("simple")), "header.", "header.", false); + popup.setFullKeys(true); + Rect area = new Rect(0, 0, 120, 30); + Buffer buffer = Buffer.empty(area); + popup.render(Frame.forTesting(buffer), area, 2, 10); + + // not name, as a property key in its group would be shown + assertThat(HealthTabRenderTest.bufferToString(buffer)).contains(" header.name "); + } + + @Test + void theHeaderNamesOfTheFile() { + List<String> lines = List.of( + "from(\"kafka:orders\")", + " .setHeader(\"orderId\", simple(\"${body}\"))", + " .filter(simple(\"${header.priority} == 'high'\"))", + "- setHeader:", + " name: region", + "<setHeader id=\"x\" name=\"customer\">"); + List<String> names = SimpleCompletions.names(catalog, lines, "Header", "header", true).stream() + .map(AutocompletePopup.CompletionItem::key).toList(); + // the ones the file sets or reads first, then the ones of the kafka component + assertThat(names).startsWith("orderId", "region", "customer", "priority"); + assertThat(names).contains("CamelKafkaKey"); + } + + @Test + void aJavaFunction() throws Exception { + SourceViewer viewer = viewer("MyRoute.java", """ + public class MyRoute extends RouteBuilder { + public void configure() { + from("timer:tick") + .setHeader("orderId", constant(1)) + .filter(simple("${headerA")); + } + } + """); + cursorAt(viewer, 4, "\"));"); + tab(viewer); + enter(viewer); + assertThat(line(viewer, 4)).isEqualTo(" .filter(simple(\"${headerAs(\"));"); + } + + @Test + void aJavaHeaderThenAnOperator() throws Exception { + // header. opens the header names right away + SourceViewer v2 = viewer("MyRoute.java", """ + public class MyRoute extends RouteBuilder { + public void configure() { + from("timer:tick") + .setHeader("orderId", constant(1)) + .filter(simple("${heade")); + } + } + """); + cursorAt(v2, 4, "\"));"); + tab(v2); + type(v2, "r."); + enter(v2); + assertThat(line(v2, 4)).isEqualTo(" .filter(simple(\"${header.\"));"); + type(v2, "ord"); + enter(v2); + assertThat(line(v2, 4)).isEqualTo(" .filter(simple(\"${header.orderId}\"));"); + + // then the operators of a predicate + type(v2, " "); + tab(v2); + type(v2, "contains"); + enter(v2); + assertThat(line(v2, 4)).isEqualTo(" .filter(simple(\"${header.orderId} contains \"));"); + } + + @Test + void aYamlFunction() throws Exception { + SourceViewer viewer = viewer("route.camel.yaml", """ + - from: + uri: timer:tick + steps: + - setBody: + simple: "Hello ${bod" + """); + cursorAt(viewer, 4, "\""); + tab(viewer); + enter(viewer); + assertThat(line(viewer, 4)).isEqualTo(" simple: \"Hello ${body}\""); + } + + @Test + void anXmlFunctionKeepsTheClosingBrace() throws Exception { + SourceViewer viewer = viewer("routes.xml", """ + <routes xmlns="http://camel.apache.org/schema/xml-io"> + <route> + <from uri="timer:tick"/> + <log message="Got ${exchangeI}"/> + </route> + </routes> + """); + cursorAt(viewer, 3, "}\"/>"); + tab(viewer); + enter(viewer); + assertThat(line(viewer, 3)).isEqualTo(" <log message=\"Got ${exchangeId}\"/>"); + } + + private SourceViewer viewer(String fileName, String src) throws Exception { + Path file = tempDir.resolve(fileName); + Files.writeString(file, src, StandardCharsets.UTF_8); + AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(List.of()); + AtomicReference<List<InfraInfo>> infraData = new AtomicReference<>(List.of()); + SourceEditAssist assist = new SourceEditAssist(new MonitorContext(data, infraData)); + SourceViewer viewer = new SourceViewer(); + viewer.setAutocompleteProvider(assist::provideYamlKeyCompletions); + viewer.setAutocompleteValueProvider(assist::provideYamlValueCompletions); + viewer.setSimpleCompletion(assist::provideSimpleCompletions); + viewer.loadFile(file); + viewer.enterEditMode(); + return viewer; + } + + /** 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 void tab(SourceViewer viewer) { + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE)); + } + + private static void enter(SourceViewer viewer) { + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + } + + private static void type(SourceViewer viewer, String text) { + for (char ch : text.toCharArray()) { + viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE)); + } + } + + private static String line(SourceViewer viewer, int row) { + return viewer.editText().split("\n")[row]; + } +}
