This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 2ba072fa2c4d24521a032ea5624e2e656eec54ca Author: Claus Ibsen <[email protected]> AuthorDate: Wed Aug 5 22:21:44 2026 +0200 camel-tui - YAML DSL tab completion for canonical format expressions, data formats, route and language options Add tab completion support for canonical YAML DSL format: - Expression key completion inside EIPs (split, filter, setHeader, etc.) - Language name completion inside expression: blocks (simple, jsonpath, xpath, etc.) - Language option completion inside language blocks (expression, resultType, trim, etc.) - Data format name completion inside marshal/unmarshal blocks - Data format option completion inside data format blocks - Route-level option completion (autoStartup, streamCache, logMask, etc.) - Value completion (boolean, enum) for language and data format options - Sort required options first, deprecated last Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/tui/SourceTab.java | 330 ++++++++++++ .../dsl/jbang/core/commands/tui/SourceViewer.java | 312 ++++++++++- .../core/commands/tui/YamlCompletionTest.java | 600 ++++++++++++++++++++- 3 files changed, 1236 insertions(+), 6 deletions(-) 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 8d4d1ddcfb3b..bc107776263a 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 @@ -957,6 +957,26 @@ class SourceTab extends AbstractTab { return provideEipKeyCompletions(context.substring(9)); } + // expression language name completion inside expression: block + if (context.startsWith("yaml-expr:")) { + return provideExpressionLanguageCompletions(context.substring(10)); + } + + // language option completion inside a language block under expression: + if (context.startsWith("yaml-lang-opt:")) { + return provideLanguageOptionCompletions(context.substring(14)); + } + + // data format name completion inside marshal/unmarshal + if (context.startsWith("yaml-df:")) { + return provideDataFormatNameCompletions(context.substring(8)); + } + + // data format option completion + if (context.startsWith("yaml-df-opt:")) { + return provideDataFormatOptionCompletions(context.substring(12)); + } + if (!context.startsWith("yaml:")) { return List.of(); } @@ -1090,6 +1110,306 @@ class SourceTab extends AbstractTab { List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); for (EipModel.EipOptionModel opt : model.getOptions()) { + if ("expression".equals(opt.getKind()) || "element".equals(opt.getKind())) { + if (EIP_BOILERPLATE.contains(opt.getName())) { + continue; + } + if (existingKeys.contains(opt.getName())) { + continue; + } + items.add(new AutocompletePopup.CompletionItem( + opt.getName(), opt.getDescription(), opt.getType(), + opt.getDefaultValue(), opt.isDeprecated(), opt.getDeprecationNote(), + opt.getGroup(), opt.isRequired())); + continue; + } + if (!"attribute".equals(opt.getKind())) { + continue; + } + if (EIP_BOILERPLATE.contains(opt.getName())) { + continue; + } + if (existingKeys.contains(opt.getName()) && !opt.isMultiValue()) { + continue; + } + items.add(new AutocompletePopup.CompletionItem( + opt.getName(), opt.getDescription(), opt.getType(), + opt.getDefaultValue(), opt.isDeprecated(), opt.getDeprecationNote(), + opt.getGroup(), opt.isRequired())); + } + + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing((a, b) -> Boolean.compare(b.required(), a.required())) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideExpressionLanguageCompletions(String contextAfterPrefix) { + CamelCatalog catalog = getCatalog(); + if (catalog == null) { + return List.of(); + } + + // context format: "eipName" or "eipName:existingKey1,existingKey2,..." + String[] parts = contextAfterPrefix.split(":", 2); + String eipName = parts[0]; + + Set<String> existingKeys = Set.of(); + if (parts.length > 1 && !parts[1].isEmpty()) { + existingKeys = new HashSet<>(Arrays.asList(parts[1].split(","))); + } + + EipModel model = catalog.eipModel(eipName); + if (model == null) { + return List.of(); + } + + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (EipModel.EipOptionModel opt : model.getOptions()) { + if (!"expression".equals(opt.getKind())) { + continue; + } + List<String> oneOfs = opt.getOneOfs(); + if (oneOfs == null || oneOfs.isEmpty()) { + continue; + } + if (oneOfs.stream().anyMatch(existingKeys::contains)) { + continue; + } + for (String langName : oneOfs) { + LanguageModel langModel = catalog.languageModel(langName); + String desc = langModel != null + ? langModel.getTitle() + " - " + langModel.getDescription() + : langName; + String label = langModel != null ? langModel.getLabel() : "language"; + boolean dep = langModel != null && langModel.isDeprecated(); + String depNote = langModel != null ? langModel.getDeprecationNote() : null; + items.add(new AutocompletePopup.CompletionItem( + langName, desc, "language", + null, dep, depNote, label)); + } + } + + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideLanguageOptionCompletions(String contextAfterPrefix) { + CamelCatalog catalog = getCatalog(); + if (catalog == null) { + return List.of(); + } + + // context format: "languageName" or "languageName:existingKey1,existingKey2,..." + String[] parts = contextAfterPrefix.split(":", 2); + String langName = parts[0]; + + Set<String> existingKeys = Set.of(); + if (parts.length > 1 && !parts[1].isEmpty()) { + existingKeys = new HashSet<>(Arrays.asList(parts[1].split(","))); + } + + LanguageModel model = catalog.languageModel(langName); + if (model == null) { + return List.of(); + } + + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (LanguageModel.LanguageOptionModel opt : model.getOptions()) { + if (!"attribute".equals(opt.getKind()) && !"value".equals(opt.getKind())) { + continue; + } + if (EIP_BOILERPLATE.contains(opt.getName())) { + continue; + } + if (existingKeys.contains(opt.getName()) && !opt.isMultiValue()) { + continue; + } + items.add(new AutocompletePopup.CompletionItem( + opt.getName(), opt.getDescription(), opt.getType(), + opt.getDefaultValue(), opt.isDeprecated(), opt.getDeprecationNote(), + opt.getGroup(), opt.isRequired())); + } + + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing((a, b) -> Boolean.compare(b.required(), a.required())) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideLanguageValueCompletions(String contextAfterPrefix) { + CamelCatalog catalog = getCatalog(); + if (catalog == null) { + return List.of(); + } + + // context format: "languageName:optionName" + String[] parts = contextAfterPrefix.split(":", 2); + if (parts.length < 2) { + return List.of(); + } + String langName = parts[0]; + String optionName = parts[1]; + + LanguageModel model = catalog.languageModel(langName); + if (model == null) { + return List.of(); + } + + LanguageModel.LanguageOptionModel opt = null; + for (LanguageModel.LanguageOptionModel o : model.getOptions()) { + if (o.getName().equals(optionName)) { + opt = o; + break; + } + } + + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + if (opt != null) { + List<String> enums = opt.getEnums(); + if (enums != null && !enums.isEmpty()) { + for (String value : enums) { + boolean isDefault = value.equals(String.valueOf(opt.getDefaultValue())); + items.add(new AutocompletePopup.CompletionItem( + value, opt.getDescription(), opt.getType(), + isDefault ? value : opt.getDefaultValue(), + false, null, opt.getGroup())); + } + } else if ("boolean".equalsIgnoreCase(opt.getType()) + || "java.lang.Boolean".equals(opt.getJavaType())) { + items.add(new AutocompletePopup.CompletionItem( + "true", opt.getDescription(), "boolean", opt.getDefaultValue(), + false, null, opt.getGroup())); + items.add(new AutocompletePopup.CompletionItem( + "false", opt.getDescription(), "boolean", opt.getDefaultValue(), + false, null, opt.getGroup())); + } + } + return items; + } + + private List<AutocompletePopup.CompletionItem> provideDataFormatValueCompletions(String contextAfterPrefix) { + CamelCatalog catalog = getCatalog(); + if (catalog == null) { + return List.of(); + } + + String[] parts = contextAfterPrefix.split(":", 2); + if (parts.length < 2) { + return List.of(); + } + String dfName = parts[0]; + String optionName = parts[1]; + + DataFormatModel model = catalog.dataFormatModel(dfName); + if (model == null) { + return List.of(); + } + + DataFormatModel.DataFormatOptionModel opt = null; + for (DataFormatModel.DataFormatOptionModel o : model.getOptions()) { + if (o.getName().equals(optionName)) { + opt = o; + break; + } + } + + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + if (opt != null) { + List<String> enums = opt.getEnums(); + if (enums != null && !enums.isEmpty()) { + for (String value : enums) { + boolean isDefault = value.equals(String.valueOf(opt.getDefaultValue())); + items.add(new AutocompletePopup.CompletionItem( + value, opt.getDescription(), opt.getType(), + isDefault ? value : opt.getDefaultValue(), + false, null, opt.getGroup())); + } + } else if ("boolean".equalsIgnoreCase(opt.getType()) + || "java.lang.Boolean".equals(opt.getJavaType())) { + items.add(new AutocompletePopup.CompletionItem( + "true", opt.getDescription(), "boolean", opt.getDefaultValue(), + false, null, opt.getGroup())); + items.add(new AutocompletePopup.CompletionItem( + "false", opt.getDescription(), "boolean", opt.getDefaultValue(), + false, null, opt.getGroup())); + } + } + return items; + } + + private List<AutocompletePopup.CompletionItem> provideDataFormatNameCompletions(String contextAfterPrefix) { + CamelCatalog catalog = getCatalog(); + if (catalog == null) { + return List.of(); + } + + // context format: "eipName" or "eipName:existingKey1,existingKey2,..." + String[] parts = contextAfterPrefix.split(":", 2); + String eipName = parts[0]; + + Set<String> existingKeys = Set.of(); + if (parts.length > 1 && !parts[1].isEmpty()) { + existingKeys = new HashSet<>(Arrays.asList(parts[1].split(","))); + } + + EipModel model = catalog.eipModel(eipName); + if (model == null) { + return List.of(); + } + + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (EipModel.EipOptionModel opt : model.getOptions()) { + List<String> oneOfs = opt.getOneOfs(); + if (oneOfs == null || oneOfs.isEmpty()) { + continue; + } + // only offer data format names if none is already specified + if (oneOfs.stream().anyMatch(existingKeys::contains)) { + continue; + } + for (String dfName : oneOfs) { + DataFormatModel dfModel = catalog.dataFormatModel(dfName); + String desc = dfModel != null + ? dfModel.getTitle() + " - " + dfModel.getDescription() + : dfName; + String label = dfModel != null ? dfModel.getLabel() : "dataformat"; + boolean dep = dfModel != null && dfModel.isDeprecated(); + String depNote = dfModel != null ? dfModel.getDeprecationNote() : null; + items.add(new AutocompletePopup.CompletionItem( + dfName, desc, "dataformat", + null, dep, depNote, label)); + } + } + + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideDataFormatOptionCompletions(String contextAfterPrefix) { + CamelCatalog catalog = getCatalog(); + if (catalog == null) { + return List.of(); + } + + // context format: "dataFormatName" or "dataFormatName:existingKey1,existingKey2,..." + String[] parts = contextAfterPrefix.split(":", 2); + String dfName = parts[0]; + + Set<String> existingKeys = Set.of(); + if (parts.length > 1 && !parts[1].isEmpty()) { + existingKeys = new HashSet<>(Arrays.asList(parts[1].split(","))); + } + + DataFormatModel model = catalog.dataFormatModel(dfName); + if (model == null) { + return List.of(); + } + + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (DataFormatModel.DataFormatOptionModel opt : model.getOptions()) { if (!"attribute".equals(opt.getKind())) { continue; } @@ -1136,6 +1456,16 @@ class SourceTab extends AbstractTab { return provideEipValueCompletions(context.substring(15)); } + // Language option value completion + if (context.startsWith("yaml-lang-value:")) { + return provideLanguageValueCompletions(context.substring(16)); + } + + // Data format option value completion + if (context.startsWith("yaml-df-value:")) { + return provideDataFormatValueCompletions(context.substring(14)); + } + if (!context.startsWith("yaml:")) { return List.of(); } 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 2fbec7cc1ac4..93e966bdf096 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 @@ -821,10 +821,25 @@ class SourceViewer { record YamlEipContext(String eipName) { } + record YamlDataFormatContext(String eipName) { + } + + record YamlDataFormatOptionContext(String dataFormatName) { + } + + record YamlExpressionContext(String eipName) { + } + + record YamlLanguageOptionContext(String languageName) { + } + private static final java.util.Set<String> STRUCTURAL_KEYS - = java.util.Set.of("steps", "uri", "parameters", "from", "route", "routeConfiguration", + = java.util.Set.of("steps", "uri", "parameters", "from", "expression", "routeConfiguration", "routeTemplate", "templatedRoute", "rest", "beans"); + private static final java.util.Set<String> MARSHAL_EIPS + = java.util.Set.of("marshal", "unmarshal"); + YamlEipContext findEnclosingEip(int fromRow) { String cursorLine = editState.getLine(fromRow); int cursorIndent = countLeadingSpaces(cursorLine); @@ -893,6 +908,191 @@ class SourceViewer { return null; } + YamlExpressionContext findExpressionContext(int fromRow) { + // cursor is inside an expression: block — find the parent EIP + String cursorLine = editState.getLine(fromRow); + int cursorIndent = countLeadingSpaces(cursorLine); + if (cursorLine.isBlank() && cursorIndent == 0) { + int lineCount = editState.lineCount(); + for (int i = fromRow + 1; i < lineCount; i++) { + String next = editState.getLine(i); + if (!next.isBlank()) { + cursorIndent = countLeadingSpaces(next); + break; + } + } + if (cursorIndent == 0) { + for (int i = fromRow - 1; i >= 0; i--) { + String prev = editState.getLine(i); + if (!prev.isBlank()) { + cursorIndent = countLeadingSpaces(prev); + break; + } + } + } + } + + // walk up to find the immediate parent key + for (int i = fromRow; i >= 0; i--) { + String line = editState.getLine(i); + if (line.isBlank()) { + continue; + } + int indent = countLeadingSpaces(line); + if (indent < cursorIndent) { + String parentKey = extractEipName(line.trim()); + if ("expression".equals(parentKey)) { + // found expression: — now find the EIP above it + YamlEipContext eipCtx = findEnclosingEip(i); + if (eipCtx != null) { + return new YamlExpressionContext(eipCtx.eipName()); + } + } + break; + } + } + return null; + } + + YamlLanguageOptionContext findLanguageOptionContext(int fromRow) { + // cursor is inside a language block (e.g., simple:) under expression: under an EIP + String cursorLine = editState.getLine(fromRow); + int cursorIndent = countLeadingSpaces(cursorLine); + if (cursorLine.isBlank() && cursorIndent == 0) { + int lineCount = editState.lineCount(); + for (int i = fromRow + 1; i < lineCount; i++) { + String next = editState.getLine(i); + if (!next.isBlank()) { + cursorIndent = countLeadingSpaces(next); + break; + } + } + if (cursorIndent == 0) { + for (int i = fromRow - 1; i >= 0; i--) { + String prev = editState.getLine(i); + if (!prev.isBlank()) { + cursorIndent = countLeadingSpaces(prev); + break; + } + } + } + } + + // walk up: parent should be a language name, grandparent should be expression: + String parentKey = null; + int parentIndent = -1; + for (int i = fromRow; i >= 0; i--) { + String line = editState.getLine(i); + if (line.isBlank()) { + continue; + } + int indent = countLeadingSpaces(line); + if (indent < cursorIndent) { + parentKey = extractEipName(line.trim()); + parentIndent = indent; + break; + } + } + if (parentKey == null || parentIndent < 0) { + return null; + } + + // grandparent should be expression: + for (int i = findLineAbove(fromRow, parentIndent); i >= 0; i--) { + String line = editState.getLine(i); + if (line.isBlank()) { + continue; + } + int indent = countLeadingSpaces(line); + if (indent < parentIndent) { + String grandparent = extractEipName(line.trim()); + if ("expression".equals(grandparent)) { + return new YamlLanguageOptionContext(parentKey); + } + break; + } + } + return null; + } + + YamlDataFormatContext findMarshalContext(int fromRow) { + YamlEipContext eipCtx = findEnclosingEip(fromRow); + if (eipCtx != null && MARSHAL_EIPS.contains(eipCtx.eipName())) { + return new YamlDataFormatContext(eipCtx.eipName()); + } + return null; + } + + YamlDataFormatOptionContext findDataFormatOptionContext(int fromRow) { + String cursorLine = editState.getLine(fromRow); + int cursorIndent = countLeadingSpaces(cursorLine); + if (cursorLine.isBlank() && cursorIndent == 0) { + int lineCount = editState.lineCount(); + for (int i = fromRow + 1; i < lineCount; i++) { + String next = editState.getLine(i); + if (!next.isBlank()) { + cursorIndent = countLeadingSpaces(next); + break; + } + } + if (cursorIndent == 0) { + for (int i = fromRow - 1; i >= 0; i--) { + String prev = editState.getLine(i); + if (!prev.isBlank()) { + cursorIndent = countLeadingSpaces(prev); + break; + } + } + } + } + + // walk up to find parent key (data format name) then grandparent (marshal/unmarshal) + String parentKey = null; + int parentIndent = -1; + for (int i = fromRow; i >= 0; i--) { + String line = editState.getLine(i); + if (line.isBlank()) { + continue; + } + int indent = countLeadingSpaces(line); + if (indent < cursorIndent) { + parentKey = extractEipName(line.trim()); + parentIndent = indent; + break; + } + } + if (parentKey == null || parentIndent < 0) { + return null; + } + + // now find the grandparent — should be marshal or unmarshal + for (int i = findLineAbove(fromRow, parentIndent); i >= 0; i--) { + String line = editState.getLine(i); + if (line.isBlank()) { + continue; + } + int indent = countLeadingSpaces(line); + if (indent < parentIndent) { + String grandparent = extractEipName(line.trim()); + if (grandparent != null && MARSHAL_EIPS.contains(grandparent)) { + return new YamlDataFormatOptionContext(parentKey); + } + break; + } + } + return null; + } + + private int findLineAbove(int fromRow, int maxIndent) { + for (int i = fromRow - 1; i >= 0; i--) { + String line = editState.getLine(i); + if (!line.isBlank() && countLeadingSpaces(line) < maxIndent) { + return i; + } + } + return -1; + } + java.util.Set<String> collectExistingSiblingKeys(int fromRow) { java.util.Set<String> keys = new java.util.LinkedHashSet<>(); String cursorLine = editState.getLine(fromRow); @@ -1251,6 +1451,116 @@ class SourceViewer { return; } + // Language option completion — cursor inside a language block (e.g., simple:) under expression: + YamlLanguageOptionContext langOptCtx = findLanguageOptionContext(row); + if (langOptCtx != null && autocompleteProvider != null) { + int colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0) { + // value completion for language option + String optionName = trimmed.substring(0, colonIdx).trim(); + String valueText = trimmed.substring(colonIdx + 1).trim(); + if (valueText.startsWith("\"") || valueText.startsWith("'")) { + valueText = valueText.substring(1); + } + if (valueText.endsWith("\"") || valueText.endsWith("'")) { + valueText = valueText.substring(0, valueText.length() - 1); + } + if (autocompleteValueProvider != null) { + String context = "yaml-lang-value:" + langOptCtx.languageName() + ":" + optionName; + List<AutocompletePopup.CompletionItem> values = autocompleteValueProvider.provide(context); + if (values != null && !values.isEmpty()) { + autocompletePopup = new AutocompletePopup(values, "", valueText, true); + } + } + } else { + // key completion for language options + String filter = trimmed; + java.util.Set<String> existing = collectExistingSiblingKeys(row); + String context = "yaml-lang-opt:" + langOptCtx.languageName(); + if (!existing.isEmpty()) { + context += ":" + String.join(",", existing); + } + List<AutocompletePopup.CompletionItem> items = autocompleteProvider.provide(context); + if (items != null && !items.isEmpty()) { + autocompletePopup = new AutocompletePopup(items, filter, filter); + autocompletePopup.setTitlePrefix(langOptCtx.languageName() + " options"); + } + } + return; + } + + // Expression language name completion — cursor inside an expression: block under an EIP + YamlExpressionContext exprCtx = findExpressionContext(row); + if (exprCtx != null && autocompleteProvider != null) { + String filter = trimmed; + java.util.Set<String> existing = collectExistingSiblingKeys(row); + String context = "yaml-expr:" + exprCtx.eipName(); + if (!existing.isEmpty()) { + context += ":" + String.join(",", existing); + } + List<AutocompletePopup.CompletionItem> items = autocompleteProvider.provide(context); + if (items != null && !items.isEmpty()) { + autocompletePopup = new AutocompletePopup(items, filter, filter); + autocompletePopup.setTitlePrefix("Languages"); + } + return; + } + + // Data format option completion — cursor inside a data format block under marshal/unmarshal + YamlDataFormatOptionContext dfOptCtx = findDataFormatOptionContext(row); + if (dfOptCtx != null && autocompleteProvider != null) { + int colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0) { + // value completion for data format option + String optionName = trimmed.substring(0, colonIdx).trim(); + String valueText = trimmed.substring(colonIdx + 1).trim(); + if (valueText.startsWith("\"") || valueText.startsWith("'")) { + valueText = valueText.substring(1); + } + if (valueText.endsWith("\"") || valueText.endsWith("'")) { + valueText = valueText.substring(0, valueText.length() - 1); + } + if (autocompleteValueProvider != null) { + String context = "yaml-df-value:" + dfOptCtx.dataFormatName() + ":" + optionName; + List<AutocompletePopup.CompletionItem> values = autocompleteValueProvider.provide(context); + if (values != null && !values.isEmpty()) { + autocompletePopup = new AutocompletePopup(values, "", valueText, true); + } + } + } else { + // key completion for data format options + String filter = trimmed; + java.util.Set<String> existing = collectExistingSiblingKeys(row); + String context = "yaml-df-opt:" + dfOptCtx.dataFormatName(); + if (!existing.isEmpty()) { + context += ":" + String.join(",", existing); + } + List<AutocompletePopup.CompletionItem> items = autocompleteProvider.provide(context); + if (items != null && !items.isEmpty()) { + autocompletePopup = new AutocompletePopup(items, filter, filter); + autocompletePopup.setTitlePrefix(dfOptCtx.dataFormatName() + " options"); + } + } + return; + } + + // Data format name completion — cursor inside a marshal/unmarshal block + YamlDataFormatContext dfCtx = findMarshalContext(row); + if (dfCtx != null && autocompleteProvider != null) { + String filter = trimmed; + java.util.Set<String> existing = collectExistingSiblingKeys(row); + String context = "yaml-df:" + dfCtx.eipName(); + if (!existing.isEmpty()) { + context += ":" + String.join(",", existing); + } + List<AutocompletePopup.CompletionItem> items = autocompleteProvider.provide(context); + if (items != null && !items.isEmpty()) { + autocompletePopup = new AutocompletePopup(items, filter, filter); + autocompletePopup.setTitlePrefix("Data Formats"); + } + return; + } + // EIP option completion — cursor is inside an EIP block (not in parameters:) YamlEipContext eipCtx = findEnclosingEip(row); if (eipCtx != null && autocompleteProvider != null) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java index 4b4822cdc3ef..b7e2105bc4d1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java @@ -28,7 +28,9 @@ import java.util.Set; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.tooling.model.ComponentModel; +import org.apache.camel.tooling.model.DataFormatModel; import org.apache.camel.tooling.model.EipModel; +import org.apache.camel.tooling.model.LanguageModel; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -807,12 +809,11 @@ class YamlCompletionTest { } @Test - void eipCompletionExcludesExpressionAndElement() { + void eipCompletionIncludesExpressionAndElementKinds() { List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("split"); - // expression and outputs are not attribute kind - assertThat(items).noneMatch(i -> i.key().equals("expression")); - assertThat(items).noneMatch(i -> i.key().equals("outputs")); + // canonical format: expression and element kinds are included as keys + assertThat(items).anyMatch(i -> i.key().equals("expression")); } @Test @@ -1048,6 +1049,443 @@ class YamlCompletionTest { assertThat(viewer.findScopeLineRow(3)).isEqualTo(3); } + // --- Canonical expression completion (expression: → language → language options) --- + + @Test + void eipCompletionIncludesExpressionKey() { + List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("split"); + + // canonical format: expression is a key, not expanded into language names + assertThat(items).anyMatch(i -> i.key().equals("expression")); + // language names should NOT appear at EIP level + assertThat(items).noneMatch(i -> i.key().equals("simple")); + assertThat(items).noneMatch(i -> i.key().equals("jsonpath")); + } + + @Test + void eipCompletionForSetHeaderIncludesExpressionKey() { + List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("setHeader"); + + assertThat(items).anyMatch(i -> i.key().equals("expression")); + assertThat(items).anyMatch(i -> i.key().equals("name")); + // no inline languages at EIP level + assertThat(items).noneMatch(i -> i.key().equals("simple")); + } + + @Test + void eipCompletionExcludesExpressionWhenAlreadySpecified() { + Set<String> existing = Set.of("expression"); + List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("split", existing); + + assertThat(items).noneMatch(i -> i.key().equals("expression")); + assertThat(items).anyMatch(i -> i.key().equals("streaming")); + } + + @Test + void expressionContextOffersLanguageNames() { + List<AutocompletePopup.CompletionItem> items = provideExpressionLanguageCompletions("split"); + + assertThat(items).anyMatch(i -> i.key().equals("simple")); + assertThat(items).anyMatch(i -> i.key().equals("jsonpath")); + assertThat(items).anyMatch(i -> i.key().equals("xpath")); + assertThat(items).anyMatch(i -> i.key().equals("constant")); + var simple = items.stream().filter(i -> i.key().equals("simple")).findFirst(); + assertThat(simple).isPresent(); + assertThat(simple.get().type()).isEqualTo("language"); + } + + @Test + void expressionContextExcludesAlreadySpecified() { + Set<String> existing = Set.of("simple"); + List<AutocompletePopup.CompletionItem> items = provideExpressionLanguageCompletions("split", existing); + + assertThat(items).isEmpty(); + } + + @Test + void languageOptionCompletionForSimple() { + List<AutocompletePopup.CompletionItem> items = provideLanguageOptionCompletions("simple"); + + assertThat(items).anyMatch(i -> i.key().equals("expression")); + assertThat(items).anyMatch(i -> i.key().equals("resultType")); + } + + @Test + void languageOptionCompletionForJsonpath() { + List<AutocompletePopup.CompletionItem> items = provideLanguageOptionCompletions("jsonpath"); + + assertThat(items).anyMatch(i -> i.key().equals("expression")); + assertThat(items).anyMatch(i -> i.key().equals("resultType")); + } + + @Test + void languageOptionCompletionExcludesExisting() { + Set<String> existing = Set.of("expression"); + List<AutocompletePopup.CompletionItem> items = provideLanguageOptionCompletions("simple", existing); + + assertThat(items).noneMatch(i -> i.key().equals("expression")); + assertThat(items).anyMatch(i -> i.key().equals("resultType")); + } + + @Test + void languageOptionCompletionExcludesBoilerplate() { + List<AutocompletePopup.CompletionItem> items = provideLanguageOptionCompletions("simple"); + + assertThat(items).noneMatch(i -> i.key().equals("id")); + assertThat(items).noneMatch(i -> i.key().equals("description")); + } + + @Test + void eipCompletionForLogDoesNotIncludeExpression() { + List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("log"); + + // log has no expression option + assertThat(items).noneMatch(i -> i.key().equals("expression")); + } + + // --- Data format name completion --- + + @Test + void dataFormatNameCompletionForMarshal() { + List<AutocompletePopup.CompletionItem> items = provideDataFormatNameCompletions("marshal"); + + assertThat(items).isNotEmpty(); + assertThat(items).anyMatch(i -> i.key().equals("json")); + assertThat(items).anyMatch(i -> i.key().equals("csv")); + assertThat(items).anyMatch(i -> i.key().equals("avro")); + // type should be "dataformat" + var json = items.stream().filter(i -> i.key().equals("json")).findFirst(); + assertThat(json).isPresent(); + assertThat(json.get().type()).isEqualTo("dataformat"); + } + + @Test + void dataFormatNameCompletionForUnmarshal() { + List<AutocompletePopup.CompletionItem> items = provideDataFormatNameCompletions("unmarshal"); + + assertThat(items).isNotEmpty(); + assertThat(items).anyMatch(i -> i.key().equals("json")); + } + + @Test + void dataFormatNameCompletionExcludesAlreadySpecified() { + Set<String> existing = Set.of("json"); + List<AutocompletePopup.CompletionItem> items = provideDataFormatNameCompletions("marshal", existing); + + // once a data format is specified, no more should appear + assertThat(items).isEmpty(); + } + + // --- Data format option completion --- + + @Test + void dataFormatOptionCompletionForJackson() { + List<AutocompletePopup.CompletionItem> items = provideDataFormatOptionCompletions("jackson"); + + assertThat(items).isNotEmpty(); + assertThat(items).anyMatch(i -> i.key().equals("prettyPrint")); + assertThat(items).anyMatch(i -> i.key().equals("unmarshalType")); + } + + @Test + void dataFormatOptionCompletionExcludesBoilerplate() { + List<AutocompletePopup.CompletionItem> items = provideDataFormatOptionCompletions("jackson"); + + assertThat(items).noneMatch(i -> i.key().equals("id")); + assertThat(items).noneMatch(i -> i.key().equals("description")); + } + + @Test + void dataFormatOptionCompletionExcludesExisting() { + Set<String> existing = Set.of("prettyPrint"); + List<AutocompletePopup.CompletionItem> items = provideDataFormatOptionCompletions("jackson", existing); + + assertThat(items).noneMatch(i -> i.key().equals("prettyPrint")); + assertThat(items).anyMatch(i -> i.key().equals("unmarshalType")); + } + + @Test + void dataFormatOptionCompletionForCsv() { + List<AutocompletePopup.CompletionItem> items = provideDataFormatOptionCompletions("csv"); + + assertThat(items).isNotEmpty(); + assertThat(items).anyMatch(i -> i.key().equals("delimiter")); + } + + // --- Expression context detection --- + + @Test + void findExpressionContextInsideExpressionBlock() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - split:", + " expression:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlExpressionContext ctx = viewer.findExpressionContext(5); + assertThat(ctx).isNotNull(); + assertThat(ctx.eipName()).isEqualTo("split"); + } + + @Test + void findExpressionContextReturnsNullOutsideExpression() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - split:", + " streaming: true", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlExpressionContext ctx = viewer.findExpressionContext(5); + assertThat(ctx).isNull(); + } + + // --- Language option context detection --- + + @Test + void findLanguageOptionContextInsideSimple() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - split:", + " expression:", + " simple:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlLanguageOptionContext ctx = viewer.findLanguageOptionContext(6); + assertThat(ctx).isNotNull(); + assertThat(ctx.languageName()).isEqualTo("simple"); + } + + @Test + void findLanguageOptionContextReturnsNullWhenParentIsNotExpression() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - split:", + " streaming:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlLanguageOptionContext ctx = viewer.findLanguageOptionContext(5); + assertThat(ctx).isNull(); + } + + // --- Marshal/unmarshal context detection --- + + @Test + void findMarshalContextInsideMarshal() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - marshal:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlDataFormatContext ctx = viewer.findMarshalContext(4); + assertThat(ctx).isNotNull(); + assertThat(ctx.eipName()).isEqualTo("marshal"); + } + + @Test + void findMarshalContextInsideUnmarshal() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - unmarshal:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlDataFormatContext ctx = viewer.findMarshalContext(4); + assertThat(ctx).isNotNull(); + assertThat(ctx.eipName()).isEqualTo("unmarshal"); + } + + @Test + void findMarshalContextReturnsNullForSplit() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - split:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlDataFormatContext ctx = viewer.findMarshalContext(4); + assertThat(ctx).isNull(); + } + + // --- Data format option context detection --- + + @Test + void findDataFormatOptionContextInsideCsv() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - marshal:", + " csv:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlDataFormatOptionContext ctx = viewer.findDataFormatOptionContext(5); + assertThat(ctx).isNotNull(); + assertThat(ctx.dataFormatName()).isEqualTo("csv"); + } + + @Test + void findDataFormatOptionContextInsideUnmarshal() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - unmarshal:", + " csv:", + " delimiter: \";\"", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlDataFormatOptionContext ctx = viewer.findDataFormatOptionContext(6); + assertThat(ctx).isNotNull(); + assertThat(ctx.dataFormatName()).isEqualTo("csv"); + } + + @Test + void findDataFormatOptionContextReturnsNullInsideSplit() throws IOException { + String yaml = String.join("\n", + "- from:", + " uri: timer:tick", + " steps:", + " - split:", + " simple:", + " ", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlDataFormatOptionContext ctx = viewer.findDataFormatOptionContext(5); + assertThat(ctx).isNull(); + } + + // --- Route-level option completion --- + + @Test + void findEnclosingEipDetectsRoute() throws IOException { + String yaml = String.join("\n", + "- route:", + " ", + " from:", + " uri: timer:tick", + ""); + + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml); + + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.enterEditMode(); + + SourceViewer.YamlEipContext ctx = viewer.findEnclosingEip(1); + assertThat(ctx).isNotNull(); + assertThat(ctx.eipName()).isEqualTo("route"); + } + + @Test + void routeEipCompletionIncludesRouteOptions() { + List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("route"); + + assertThat(items).anyMatch(i -> i.key().equals("autoStartup")); + assertThat(items).anyMatch(i -> i.key().equals("streamCache")); + assertThat(items).anyMatch(i -> i.key().equals("logMask")); + assertThat(items).anyMatch(i -> i.key().equals("messageHistory")); + } + + @Test + void routeEipCompletionExcludesStructural() { + List<AutocompletePopup.CompletionItem> items = provideEipKeyCompletions("route"); + + // from and steps are not attribute kind, should not appear + assertThat(items).noneMatch(i -> i.key().equals("from")); + } + // --- Helpers that replicate SourceTab logic for testing --- private List<AutocompletePopup.CompletionItem> provideKeyCompletions(String componentName, String role) { @@ -1171,6 +1609,19 @@ class YamlCompletionTest { } List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); for (EipModel.EipOptionModel opt : model.getOptions()) { + if ("expression".equals(opt.getKind()) || "element".equals(opt.getKind())) { + if (EIP_BOILERPLATE.contains(opt.getName())) { + continue; + } + if (existingKeys.contains(opt.getName())) { + continue; + } + items.add(new AutocompletePopup.CompletionItem( + opt.getName(), opt.getDescription(), opt.getType(), + opt.getDefaultValue(), opt.isDeprecated(), opt.getDeprecationNote(), + opt.getGroup(), opt.isRequired())); + continue; + } if (!"attribute".equals(opt.getKind())) { continue; } @@ -1186,7 +1637,78 @@ class YamlCompletionTest { opt.getGroup(), opt.isRequired())); } items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) - .thenComparing(ci -> !ci.required()) + .thenComparing((a, b) -> Boolean.compare(b.required(), a.required())) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideExpressionLanguageCompletions(String eipName) { + return provideExpressionLanguageCompletions(eipName, Set.of()); + } + + private List<AutocompletePopup.CompletionItem> provideExpressionLanguageCompletions( + String eipName, Set<String> existingKeys) { + EipModel model = catalog.eipModel(eipName); + if (model == null) { + return List.of(); + } + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (EipModel.EipOptionModel opt : model.getOptions()) { + if (!"expression".equals(opt.getKind())) { + continue; + } + List<String> oneOfs = opt.getOneOfs(); + if (oneOfs == null || oneOfs.isEmpty()) { + continue; + } + if (oneOfs.stream().anyMatch(existingKeys::contains)) { + continue; + } + for (String langName : oneOfs) { + LanguageModel langModel = catalog.languageModel(langName); + String desc = langModel != null + ? langModel.getTitle() + " - " + langModel.getDescription() + : langName; + String label = langModel != null ? langModel.getLabel() : "language"; + boolean dep = langModel != null && langModel.isDeprecated(); + String depNote = langModel != null ? langModel.getDeprecationNote() : null; + items.add(new AutocompletePopup.CompletionItem( + langName, desc, "language", null, dep, depNote, label)); + } + } + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideLanguageOptionCompletions(String langName) { + return provideLanguageOptionCompletions(langName, Set.of()); + } + + private List<AutocompletePopup.CompletionItem> provideLanguageOptionCompletions( + String langName, Set<String> existingKeys) { + LanguageModel model = catalog.languageModel(langName); + if (model == null) { + return List.of(); + } + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (LanguageModel.LanguageOptionModel opt : model.getOptions()) { + if (!"attribute".equals(opt.getKind()) && !"value".equals(opt.getKind())) { + continue; + } + if (EIP_BOILERPLATE.contains(opt.getName())) { + continue; + } + if (existingKeys.contains(opt.getName()) && !opt.isMultiValue()) { + continue; + } + items.add(new AutocompletePopup.CompletionItem( + opt.getName(), opt.getDescription(), opt.getType(), + opt.getDefaultValue(), opt.isDeprecated(), opt.getDeprecationNote(), + opt.getGroup(), opt.isRequired())); + } + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing((a, b) -> Boolean.compare(b.required(), a.required())) .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); return items; } @@ -1242,6 +1764,74 @@ class YamlCompletionTest { return items; } + private List<AutocompletePopup.CompletionItem> provideDataFormatNameCompletions(String eipName) { + return provideDataFormatNameCompletions(eipName, Set.of()); + } + + private List<AutocompletePopup.CompletionItem> provideDataFormatNameCompletions( + String eipName, Set<String> existingKeys) { + EipModel model = catalog.eipModel(eipName); + if (model == null) { + return List.of(); + } + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (EipModel.EipOptionModel opt : model.getOptions()) { + List<String> oneOfs = opt.getOneOfs(); + if (oneOfs == null || oneOfs.isEmpty()) { + continue; + } + if (oneOfs.stream().anyMatch(existingKeys::contains)) { + continue; + } + for (String dfName : oneOfs) { + DataFormatModel dfModel = catalog.dataFormatModel(dfName); + String desc = dfModel != null + ? dfModel.getTitle() + " - " + dfModel.getDescription() + : dfName; + String label = dfModel != null ? dfModel.getLabel() : "dataformat"; + boolean dep = dfModel != null && dfModel.isDeprecated(); + String depNote = dfModel != null ? dfModel.getDeprecationNote() : null; + items.add(new AutocompletePopup.CompletionItem( + dfName, desc, "dataformat", null, dep, depNote, label)); + } + } + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + + private List<AutocompletePopup.CompletionItem> provideDataFormatOptionCompletions(String dfName) { + return provideDataFormatOptionCompletions(dfName, Set.of()); + } + + private List<AutocompletePopup.CompletionItem> provideDataFormatOptionCompletions( + String dfName, Set<String> existingKeys) { + DataFormatModel model = catalog.dataFormatModel(dfName); + if (model == null) { + return List.of(); + } + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + for (DataFormatModel.DataFormatOptionModel opt : model.getOptions()) { + if (!"attribute".equals(opt.getKind())) { + continue; + } + if (EIP_BOILERPLATE.contains(opt.getName())) { + continue; + } + if (existingKeys.contains(opt.getName()) && !opt.isMultiValue()) { + continue; + } + items.add(new AutocompletePopup.CompletionItem( + opt.getName(), opt.getDescription(), opt.getType(), + opt.getDefaultValue(), opt.isDeprecated(), opt.getDeprecationNote(), + opt.getGroup(), opt.isRequired())); + } + items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated) + .thenComparing(ci -> !ci.required()) + .thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER)); + return items; + } + private List<AutocompletePopup.CompletionItem> loadPlaceholders(Path dir) { List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); try (var stream = Files.list(dir)) {
