gnodet-bot commented on code in PR #27839:
URL: https://github.com/apache/camel/pull/27839#discussion_r4273015074
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -250,6 +309,10 @@ public static JsonObject catalogDoc(
// (jackson): both are answered, with the YAML shape in the answer
(CAMEL-24898)
String artifact =
YAML_DATAFORMAT_NAMES.getOrDefault(name.toLowerCase(), name);
DataFormatModel dm = catalog.dataFormatModel(artifact);
+ if (dm == null && ignoringCase(catalog.findDataFormatNames(),
artifact) != null) {
+ artifact = ignoringCase(catalog.findDataFormatNames(),
artifact);
Review Comment:
🔧 **Nit: `ignoringCase(catalog.findDataFormatNames(), artifact)` called
twice**
The null-guard check and the actual lookup both invoke `ignoringCase` with
identical arguments, which iterates the names list twice. Cache the result:
```suggestion
String resolvedArtifact =
ignoringCase(catalog.findDataFormatNames(), artifact);
if (dm == null && resolvedArtifact != null) {
artifact = resolvedArtifact;
```
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -283,24 +362,32 @@ public static JsonObject catalogDoc(
// the start of the page unless the whole of it, or one of its
sub-pages, was asked for
String excerpt = docPageOnly || includeDoc
? null : docExcerpt(catalog.asciiDoc(name +
"-language"), DOC_EXCERPT_BUDGET_PROSE);
- return languageDoc(lm, lowerFilter, scope, doc,
languageDocPages(catalog, name), docPageOnly, excerpt);
+ JsonObject answer
+ = languageDoc(lm, lowerFilter, scope, doc,
languageDocPages(catalog, name), docPageOnly, excerpt);
+ if (sectionTitle != null) {
+ answer.put("section", sectionTitle);
+ }
+ return answer;
}
if (kind != null) {
return notFound("Language", name,
catalog.suggestLanguageNames(name, 5));
}
}
if (kind == null || "eip".equals(kind)) {
EipModel em = catalog.eipModel(name);
+ if (em == null && ignoringCase(catalog.findModelNames(), name) !=
null) {
+ em = catalog.eipModel(ignoringCase(catalog.findModelNames(),
name));
Review Comment:
🔧 **Nit: `ignoringCase(catalog.findModelNames(), name)` called twice**
Same pattern — cache the result:
```suggestion
String resolvedName = ignoringCase(catalog.findModelNames(),
name);
if (em == null && resolvedName != null) {
em = catalog.eipModel(resolvedName);
```
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -267,14 +330,30 @@ public static JsonObject catalogDoc(
}
if (kind == null || "language".equals(kind)) {
LanguageModel lm = catalog.languageModel(name);
+ if (lm == null && ignoringCase(catalog.findLanguageNames(), name)
!= null) {
+ name = ignoringCase(catalog.findLanguageNames(), name);
Review Comment:
🔧 **Nit: `ignoringCase(catalog.findLanguageNames(), name)` called twice**
Same pattern as data format — cache the result:
```suggestion
String resolvedName = ignoringCase(catalog.findLanguageNames(),
name);
if (lm == null && resolvedName != null) {
name = resolvedName;
```
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness
(the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the
sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(),
filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that
section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + "
contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter
matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with
their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full
and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the
prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how,
OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ?
model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option:
camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme()
+ "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null &&
opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ?
DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted,
scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o ->
shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed
in full (required first, then the"
+ + " endpoint options), the others by name in
moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full,
includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " +
omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt +
param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see
closestOptions, prefixOptions and the"
+ + " sections of the page below
(docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by
edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String
filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ?
model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT),
k -> new ArrayList<>()).add(opt);
+ for (String word :
opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT),
k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word :
EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter
with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option
is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null &&
seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is
passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets
its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the
component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how,
String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server
is configured by the " + group
+ + ".* keys of application.properties, not by
an option of this component:"
+ + " camel.server.enabled=true and
camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group +
" lists the keys");
+ if (how.filter() == null) {
+ return;
+ }
+ JsonArray keys = new JsonArray();
+ try {
+ for (BaseOptionModel o :
optionsUnder(how.catalog().mainModel().getOptions(), group)) {
+ if (matchesOptionFilter(o, how.filter())) {
+ keys.add(mainOptionToJson(o));
+ }
+ }
+ } catch (Exception e) {
+ return;
+ }
+ if (!keys.isEmpty()) {
+ result.put("mainOptions", keys);
+ }
+ }
+
+ /**
+ * The name the documentation page of a component has: its scheme, else
the artifact (smtp, imap and pop3 are on the
+ * mail page of camel-mail); null when there is none.
+ */
+ private static String componentDocName(CamelCatalog catalog,
ComponentModel model) {
+ if (catalog.asciiDoc(model.getScheme() + "-component") != null) {
+ return model.getScheme();
+ }
+ String artifact = model.getArtifactId();
+ if (artifact != null && artifact.startsWith("camel-")) {
+ String name = artifact.substring("camel-".length());
+ if (catalog.asciiDoc(name + "-component") != null) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String titleWords(String text) {
+ return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "
").strip();
+ }
+
+ /**
+ * A section of an AsciiDoc page: its title, its level (2 for ==), its
text with its subsections, and its own text
+ * up to its first subsection.
+ */
+ record DocSection(String title, int level, String text, String own) {
+ }
+
+ private static final Pattern HEADING = Pattern.compile("^(={2,4})
+(\\S.*)$");
+
+ /** The sections of a page, from == to ====, in the order of the page;
empty when there is no page. */
+ static List<DocSection> docSections(String adoc) {
+ List<DocSection> answer = new ArrayList<>();
+ if (adoc == null) {
+ return answer;
+ }
+ String[] lines = adoc.split("\n", -1);
+ List<Integer> starts = new ArrayList<>();
+ List<Integer> levels = new ArrayList<>();
+ List<String> titles = new ArrayList<>();
+ boolean fenced = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].strip();
+ if (trimmed.startsWith("----") || trimmed.equals("....")) {
Review Comment:
⚠️ **Bug: mixed fenced-block delimiters share one boolean**
AsciiDoc has two distinct fenced-block types:
- `----` delimiters open/close source/listing blocks
- `....` delimiters open/close literal blocks
A literal block (`....`) can legally appear *inside* a source block (e.g. to
show sample output). When that happens the current code toggles `fenced` back
to `false` on the inner `....`, causing heading-detection to run inside the
still-open outer `----` block and producing phantom `DocSection` entries.
Fix: track the two delimiter types separately, or at least ensure you only
toggle back on the *same* delimiter that opened the block:
```suggestion
if (trimmed.startsWith("----")) {
fenced = !fenced;
continue;
}
if (trimmed.equals("....") && !fenced) {
fenced = true;
continue;
}
if (trimmed.equals("....")) {
fenced = false;
continue;
}
```
A cleaner alternative is to record the *opening* delimiter and only close on
a matching one.
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness
(the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the
sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(),
filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that
section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + "
contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter
matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with
their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full
and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the
prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how,
OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ?
model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option:
camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme()
+ "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null &&
opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ?
DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted,
scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o ->
shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed
in full (required first, then the"
+ + " endpoint options), the others by name in
moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full,
includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " +
omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt +
param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see
closestOptions, prefixOptions and the"
+ + " sections of the page below
(docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by
edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String
filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ?
model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT),
k -> new ArrayList<>()).add(opt);
+ for (String word :
opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT),
k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word :
EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter
with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option
is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null &&
seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is
passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets
its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the
component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how,
String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server
is configured by the " + group
+ + ".* keys of application.properties, not by
an option of this component:"
+ + " camel.server.enabled=true and
camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group +
" lists the keys");
+ if (how.filter() == null) {
+ return;
+ }
+ JsonArray keys = new JsonArray();
+ try {
+ for (BaseOptionModel o :
optionsUnder(how.catalog().mainModel().getOptions(), group)) {
+ if (matchesOptionFilter(o, how.filter())) {
+ keys.add(mainOptionToJson(o));
+ }
+ }
+ } catch (Exception e) {
+ return;
+ }
+ if (!keys.isEmpty()) {
+ result.put("mainOptions", keys);
+ }
+ }
+
+ /**
+ * The name the documentation page of a component has: its scheme, else
the artifact (smtp, imap and pop3 are on the
+ * mail page of camel-mail); null when there is none.
+ */
+ private static String componentDocName(CamelCatalog catalog,
ComponentModel model) {
+ if (catalog.asciiDoc(model.getScheme() + "-component") != null) {
+ return model.getScheme();
+ }
+ String artifact = model.getArtifactId();
+ if (artifact != null && artifact.startsWith("camel-")) {
+ String name = artifact.substring("camel-".length());
+ if (catalog.asciiDoc(name + "-component") != null) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String titleWords(String text) {
+ return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "
").strip();
+ }
+
+ /**
+ * A section of an AsciiDoc page: its title, its level (2 for ==), its
text with its subsections, and its own text
+ * up to its first subsection.
+ */
+ record DocSection(String title, int level, String text, String own) {
+ }
+
+ private static final Pattern HEADING = Pattern.compile("^(={2,4})
+(\\S.*)$");
+
+ /** The sections of a page, from == to ====, in the order of the page;
empty when there is no page. */
+ static List<DocSection> docSections(String adoc) {
+ List<DocSection> answer = new ArrayList<>();
+ if (adoc == null) {
+ return answer;
+ }
+ String[] lines = adoc.split("\n", -1);
+ List<Integer> starts = new ArrayList<>();
+ List<Integer> levels = new ArrayList<>();
+ List<String> titles = new ArrayList<>();
+ boolean fenced = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].strip();
+ if (trimmed.startsWith("----") || trimmed.equals("....")) {
+ fenced = !fenced;
+ continue;
+ }
+ Matcher m = fenced ? null : HEADING.matcher(lines[i]);
+ if (m != null && m.matches()) {
+ starts.add(i);
+ levels.add(m.group(1).length());
+ titles.add(m.group(2).strip());
+ }
+ }
+ for (int h = 0; h < starts.size(); h++) {
+ int end = lines.length;
+ for (int n = h + 1; n < starts.size(); n++) {
+ if (levels.get(n) <= levels.get(h)) {
+ end = starts.get(n);
+ break;
+ }
+ }
+ int ownEnd = h + 1 < starts.size() ? Math.min(end, starts.get(h +
1)) : end;
+ String text = String.join("\n",
Arrays.asList(lines).subList(starts.get(h), end)).strip();
+ String own = String.join("\n",
Arrays.asList(lines).subList(starts.get(h), ownEnd)).strip();
+ answer.add(new DocSection(titles.get(h), levels.get(h), text,
own));
+ }
+ return answer;
+ }
+
+ /**
+ * The titles of the sections, those whose own text contains the filter
first (SSL support before the Usage section
+ * it is part of).
+ */
+ static JsonArray sectionTitles(List<DocSection> sections, String filter) {
+ JsonArray first = new JsonArray();
+ JsonArray rest = new JsonArray();
+ for (DocSection s : sections) {
+ if (filter != null &&
s.own().toLowerCase(Locale.ROOT).contains(filter)) {
+ first.add(s.title());
+ } else {
+ rest.add(s.title());
+ }
+ }
+ first.addAll(rest);
+ return first;
+ }
+
+ private static int sectionsContaining(List<DocSection> sections, String
filter) {
+ return (int) sections.stream().filter(s ->
s.own().toLowerCase(Locale.ROOT).contains(filter)).count();
+ }
+
+ /**
+ * The section a docPage names: by its title ignoring case and punctuation
(also the AsciiDoc id, such as
+ * _ssl_support), else the first whose title contains it; null when none
does.
+ */
+ static DocSection findSection(List<DocSection> sections, String page) {
+ String key = normalizeName(page);
+ if (key.isEmpty()) {
+ return null;
+ }
+ for (DocSection s : sections) {
+ if (key.equals(normalizeName(s.title()))) {
+ return s;
+ }
+ }
+ // else the words of the title from one of them on (usable
parameters), not a run of letters across words
+ String words = " " + titleWords(page);
+ for (DocSection s : sections) {
+ if ((" " + titleWords(s.title())).contains(words)) {
+ return s;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Cuts an answer longer than {@link #MAX_ANSWER_LENGTH} characters, with
a marker that says what was cut and how to
+ * ask for it: first the text of a page, at a line and down to {@link
#MIN_DOC_KEPT} characters, with the titles of
+ * the sections left out; then the longest list, from its end (the options
left out are named in moreOptions).
+ */
+ static JsonObject capAnswer(JsonObject answer) {
+ String doc = answer.getString("doc");
+ int excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
+ if (excess > 0 && doc != null && doc.length() > MIN_DOC_KEPT) {
+ // the text is escaped in the JSON, so cut by the share of the
escaped length that has to go
+ int escaped = Jsoner.serialize(doc).length();
+ int keep = (int) ((long) doc.length() * Math.max(0, escaped -
excess - 600) / escaped);
+ keep = Math.max(MIN_DOC_KEPT, keep);
+ int at = doc.lastIndexOf('\n', keep);
+ at = at > keep / 2 ? at : keep;
+ List<String> left = new ArrayList<>();
+ for (DocSection s : docSections(doc.substring(at))) {
+ if (left.size() < 30) {
+ left.add(s.title());
+ }
+ }
+ answer.put("doc", doc.substring(0, at) + "\n\n[cut at " +
MAX_ANSWER_LENGTH + " characters"
+ + (left.isEmpty() ? "" : "; the page goes on
with: " + String.join(" | ", left))
+ + "; docPage=<section title> returns one
section]");
+ answer.put("truncated", true);
+ }
+ Map<String, Integer> cut = new LinkedHashMap<>();
+ for (int round = 0; round < 10; round++) {
+ excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
+ if (excess <= 0) {
+ return answer;
+ }
+ String longest = null;
+ int longestSize = 0;
+ for (Map.Entry<String, Object> e : answer.entrySet()) {
+ if (e.getValue() instanceof JsonArray arr && arr.size() > 1) {
+ int size = arr.toJson().length();
+ if (size > longestSize) {
+ longest = e.getKey();
+ longestSize = size;
+ }
+ }
+ }
+ if (longest == null) {
+ return answer;
Review Comment:
💡 **Edge case: `capAnswer` can return an oversized answer when no shrinkable
array remains**
When all arrays have been reduced to `size() == 1` (or there are none),
`longest == null` causes an early return even though `excess > 0`. Excess
contributed by many small fields or by arrays already at size 1 is never
reduced.
Consider logging a warning or — if the constraint is a hard requirement —
truncating a string field (`"doc"`, `"description"`) or the entire answer as a
last-resort fallback before returning:
```suggestion
if (longest == null) {
// No more arrays to shrink; return as-is (may still exceed
MAX_ANSWER_LENGTH).
return answer;
}
```
At minimum, a comment noting this known limitation would help future
maintainers understand the intent.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]