Skip to content

Commit 41e2582

Browse files
committed
feature(api): Rewrite ComponentFlattener to remove recursion and add a configurable maximum nesting depth
1 parent 92a5a6e commit 41e2582

File tree

3 files changed

+160
-22
lines changed

3 files changed

+160
-22
lines changed

api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattener.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,21 @@
3131
import net.kyori.adventure.util.Buildable;
3232
import org.jetbrains.annotations.NotNull;
3333
import org.jetbrains.annotations.Nullable;
34+
import org.jetbrains.annotations.Range;
3435

3536
/**
3637
* A 'flattener' to convert a component tree to a linear string for display.
3738
*
3839
* @since 4.7.0
3940
*/
4041
public interface ComponentFlattener extends Buildable<ComponentFlattener, ComponentFlattener.Builder> {
42+
/**
43+
* A constant representing a flattener with no limit on nested flatten calls.
44+
*
45+
* @since 4.22.0
46+
*/
47+
int NO_NESTING_LIMIT = -1;
48+
4149
/**
4250
* Create a new builder for a flattener.
4351
*
@@ -123,5 +131,16 @@ interface Builder extends AbstractBuilder<ComponentFlattener>, Buildable.Builder
123131
* @since 4.7.0
124132
*/
125133
@NotNull Builder unknownMapper(final @Nullable Function<Component, String> converter);
134+
135+
/**
136+
* Sets the limit of nested flatten calls.
137+
*
138+
* <p>The default value is {@link #NO_NESTING_LIMIT}, which means there is no limit on nesting.</p>
139+
*
140+
* @param limit the new limit (must be a positive integer, or {@link #NO_NESTING_LIMIT})
141+
* @return this builder
142+
* @since 4.22.0
143+
*/
144+
@NotNull Builder nestingLimit(final @Range(from = 1, to = Integer.MAX_VALUE) int limit);
126145
}
127146
}

api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
*/
2424
package net.kyori.adventure.text.flattener;
2525

26+
import java.util.ArrayDeque;
27+
import java.util.Deque;
28+
import java.util.List;
2629
import java.util.function.BiConsumer;
2730
import java.util.function.Consumer;
2831
import java.util.function.Function;
@@ -36,6 +39,7 @@
3639
import net.kyori.adventure.util.InheritanceAwareMap;
3740
import org.jetbrains.annotations.NotNull;
3841
import org.jetbrains.annotations.Nullable;
42+
import org.jetbrains.annotations.Range;
3943

4044
import static java.util.Objects.requireNonNull;
4145

@@ -64,94 +68,136 @@ final class ComponentFlattenerImpl implements ComponentFlattener {
6468

6569
private final InheritanceAwareMap<Component, Handler> flatteners;
6670
private final Function<Component, String> unknownHandler;
71+
private final int maxNestedDepth;
6772

68-
ComponentFlattenerImpl(final InheritanceAwareMap<Component, Handler> flatteners, final @Nullable Function<Component, String> unknownHandler) {
73+
ComponentFlattenerImpl(final InheritanceAwareMap<Component, Handler> flatteners, final @Nullable Function<Component, String> unknownHandler, final int maxNestedDepth) {
6974
this.flatteners = flatteners;
7075
this.unknownHandler = unknownHandler;
76+
this.maxNestedDepth = maxNestedDepth;
77+
}
78+
79+
private static final class StackEntry {
80+
final Component component;
81+
final int depth;
82+
83+
StackEntry(final Component component, final int depth) {
84+
this.component = component;
85+
this.depth = depth;
86+
}
7187
}
7288

7389
@Override
7490
public void flatten(final @NotNull Component input, final @NotNull FlattenerListener listener) {
75-
this.flatten0(input, listener, 0);
91+
this.flatten0(input, listener, 0, 0);
7692
}
7793

78-
private void flatten0(final @NotNull Component input, final @NotNull FlattenerListener listener, final int depth) {
94+
private void flatten0(final @NotNull Component input, final @NotNull FlattenerListener listener, final int depth, final int nestedDepth) {
7995
requireNonNull(input, "input");
8096
requireNonNull(listener, "listener");
8197
if (input == Component.empty()) return;
82-
if (depth > MAX_DEPTH) {
83-
throw new IllegalStateException("Exceeded maximum depth of " + MAX_DEPTH + " while attempting to flatten components!");
98+
99+
if (this.maxNestedDepth != ComponentFlattener.NO_NESTING_LIMIT && nestedDepth > this.maxNestedDepth) {
100+
throw new IllegalStateException("Exceeded maximum nesting depth of " + this.maxNestedDepth + " while attempting to flatten components!");
84101
}
85102

86-
final @Nullable Handler flattener = this.flattener(input);
87-
final Style inputStyle = input.style();
103+
final Deque<StackEntry> componentStack = new ArrayDeque<>();
104+
final Deque<Style> styleStack = new ArrayDeque<>();
105+
106+
// Push the starting component.
107+
componentStack.push(new StackEntry(input, depth));
108+
109+
while (!componentStack.isEmpty()) {
110+
final StackEntry entry = componentStack.pop();
111+
final int currentDepth = entry.depth;
112+
113+
if (currentDepth > MAX_DEPTH) {
114+
throw new IllegalStateException("Exceeded maximum depth of " + MAX_DEPTH + " while attempting to flatten components!");
115+
}
116+
117+
final Component component = entry.component;
118+
final @Nullable Handler flattener = this.flattener(component);
119+
final Style componentStyle = component.style();
120+
121+
// Push the style to both the listener and the stack (so we can pop later).
122+
listener.pushStyle(componentStyle);
123+
styleStack.push(componentStyle);
88124

89-
listener.pushStyle(inputStyle);
90-
try {
91125
if (flattener != null) {
92-
flattener.handle(this, input, listener, depth + 1);
126+
flattener.handle(this, component, listener, currentDepth, nestedDepth);
93127
}
94128

95-
if (!input.children().isEmpty() && listener.shouldContinue()) {
96-
for (final Component child : input.children()) {
97-
this.flatten0(child, listener, depth + 1);
129+
if (!component.children().isEmpty() && listener.shouldContinue()) {
130+
// Push any children onto the stack in reverse order so they are popped in the right order.
131+
final List<Component> children = component.children();
132+
for (int i = children.size() - 1; i >= 0; i--) {
133+
componentStack.push(new StackEntry(children.get(i), currentDepth + 1));
98134
}
135+
} else {
136+
// If there are no children, we pop the latest style to go back "up" the tree.
137+
final Style style = styleStack.pop();
138+
listener.popStyle(style);
99139
}
100-
} finally {
101-
listener.popStyle(inputStyle);
140+
}
141+
142+
// Pop any remaining styles at the end.
143+
while (!styleStack.isEmpty()) {
144+
final Style style = styleStack.pop();
145+
listener.popStyle(style);
102146
}
103147
}
104148

105149
private <T extends Component> @Nullable Handler flattener(final T test) {
106150
final Handler flattener = this.flatteners.get(test.getClass());
107151

108152
if (flattener == null && this.unknownHandler != null) {
109-
return (self, component, listener, depth) -> listener.component(this.unknownHandler.apply(component));
153+
return (self, component, listener, depth, nestedDepth) -> listener.component(this.unknownHandler.apply(component));
110154
} else {
111155
return flattener;
112156
}
113157
}
114158

115159
@Override
116160
public ComponentFlattener.@NotNull Builder toBuilder() {
117-
return new BuilderImpl(this.flatteners, this.unknownHandler);
161+
return new BuilderImpl(this.flatteners, this.unknownHandler, this.maxNestedDepth);
118162
}
119163

120164
// A function that allows nesting other flatten operations
121165
@FunctionalInterface
122166
interface Handler {
123-
void handle(final ComponentFlattenerImpl self, final Component input, final FlattenerListener listener, final int depth);
167+
void handle(final ComponentFlattenerImpl self, final Component input, final FlattenerListener listener, final int depth, final int nestedDepth);
124168
}
125169

126170
static final class BuilderImpl implements Builder {
127171
private final InheritanceAwareMap.Builder<Component, Handler> flatteners;
128172
private @Nullable Function<Component, String> unknownHandler;
173+
private int maxNestedDepth = ComponentFlattener.NO_NESTING_LIMIT;
129174

130175
BuilderImpl() {
131176
this.flatteners = InheritanceAwareMap.<Component, Handler>builder().strict(true);
132177
}
133178

134-
BuilderImpl(final InheritanceAwareMap<Component, Handler> flatteners, final @Nullable Function<Component, String> unknownHandler) {
179+
BuilderImpl(final InheritanceAwareMap<Component, Handler> flatteners, final @Nullable Function<Component, String> unknownHandler, final int maxNestedDepth) {
135180
this.flatteners = InheritanceAwareMap.builder(flatteners).strict(true);
136181
this.unknownHandler = unknownHandler;
182+
this.maxNestedDepth = maxNestedDepth;
137183
}
138184

139185
@Override
140186
public @NotNull ComponentFlattener build() {
141-
return new ComponentFlattenerImpl(this.flatteners.build(), this.unknownHandler);
187+
return new ComponentFlattenerImpl(this.flatteners.build(), this.unknownHandler, this.maxNestedDepth);
142188
}
143189

144190
@Override
145191
@SuppressWarnings("unchecked")
146192
public <T extends Component> ComponentFlattener.@NotNull Builder mapper(final @NotNull Class<T> type, final @NotNull Function<T, String> converter) {
147-
this.flatteners.put(type, (self, component, listener, depth) -> listener.component(converter.apply((T) component)));
193+
this.flatteners.put(type, (self, component, listener, depth, nestedDepth) -> listener.component(converter.apply((T) component)));
148194
return this;
149195
}
150196

151197
@Override
152198
@SuppressWarnings("unchecked")
153199
public <T extends Component> ComponentFlattener.@NotNull Builder complexMapper(final @NotNull Class<T> type, final @NotNull BiConsumer<T, Consumer<Component>> converter) {
154-
this.flatteners.put(type, (self, component, listener, depth) -> converter.accept((T) component, c -> self.flatten0(c, listener, depth)));
200+
this.flatteners.put(type, (self, component, listener, depth, nestedDepth) -> converter.accept((T) component, c -> self.flatten0(c, listener, depth, nestedDepth + 1)));
155201
return this;
156202
}
157203

@@ -160,5 +206,13 @@ static final class BuilderImpl implements Builder {
160206
this.unknownHandler = converter;
161207
return this;
162208
}
209+
210+
@Override
211+
public @NotNull Builder nestingLimit(final @Range(from = 1, to = Integer.MAX_VALUE) int limit) {
212+
// noinspection ConstantValue (the Range annotation tells IDEA this will never happen, but API users could ignore that)
213+
if (limit != ComponentFlattener.NO_NESTING_LIMIT && limit < 1) throw new IllegalArgumentException("limit must be positive or ComponentFlattener.NO_NESTING_LIMIT");
214+
this.maxNestedDepth = limit;
215+
return this;
216+
}
163217
}
164218
}

api/src/test/java/net/kyori/adventure/text/flattener/ComponentFlattenerTest.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,25 @@
2727
import java.util.Arrays;
2828
import java.util.List;
2929
import java.util.Locale;
30+
import java.util.regex.Matcher;
31+
import java.util.regex.Pattern;
3032
import net.kyori.adventure.text.BlockNBTComponent;
3133
import net.kyori.adventure.text.Component;
3234
import net.kyori.adventure.text.NBTComponent;
3335
import net.kyori.adventure.text.TranslatableComponent;
36+
import net.kyori.adventure.text.TranslationArgument;
3437
import net.kyori.adventure.text.format.NamedTextColor;
3538
import net.kyori.adventure.text.format.Style;
3639
import net.kyori.adventure.text.format.TextDecoration;
3740
import org.jetbrains.annotations.NotNull;
41+
import org.jetbrains.annotations.Nullable;
3842
import org.junit.jupiter.api.Assertions;
3943
import org.junit.jupiter.api.Test;
4044

4145
import static org.junit.jupiter.api.Assertions.assertEquals;
4246
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
4347
import static org.junit.jupiter.api.Assertions.assertThrows;
48+
import static org.junit.jupiter.api.Assertions.assertTrue;
4449

4550
class ComponentFlattenerTest {
4651
static class TrackingFlattener implements FlattenerListener {
@@ -288,4 +293,64 @@ void testEarlyExit() {
288293
.assertPushesAndPops(3)
289294
.assertContents("Hello", "How are you?", "Not great");
290295
}
296+
297+
private static final Pattern PAPERS_WEIRD_LOCALIZATION_PATTERN = Pattern.compile("%(?:(\\d+)\\$)?s");
298+
public static final ComponentFlattener PAPERS_WEIRD_FLATTENER = ComponentFlattener.basic().toBuilder()
299+
.complexMapper(TranslatableComponent.class, (translatable, consumer) -> {
300+
final String key = translatable.key();
301+
final Matcher matcher = PAPERS_WEIRD_LOCALIZATION_PATTERN.matcher(key);
302+
final List<TranslationArgument> args = translatable.arguments();
303+
int argPosition = 0;
304+
int lastIdx = 0;
305+
while (matcher.find()) {
306+
// append prior
307+
if (lastIdx < matcher.start()) {
308+
consumer.accept(Component.text(key.substring(lastIdx, matcher.start())));
309+
}
310+
lastIdx = matcher.end();
311+
312+
final @Nullable String argIdx = matcher.group(1);
313+
// calculate argument position
314+
if (argIdx != null) {
315+
try {
316+
final int idx = Integer.parseInt(argIdx) - 1;
317+
if (idx < args.size()) {
318+
consumer.accept(args.get(idx).asComponent());
319+
}
320+
} catch (final NumberFormatException ex) {
321+
// ignore, drop the format placeholder
322+
}
323+
} else {
324+
final int idx = argPosition++;
325+
if (idx < args.size()) {
326+
consumer.accept(args.get(idx).asComponent());
327+
}
328+
}
329+
}
330+
331+
// append tail
332+
if (lastIdx < key.length()) {
333+
consumer.accept(Component.text(key.substring(lastIdx)));
334+
}
335+
})
336+
.nestingLimit(4)
337+
.build();
338+
339+
public static Component createNestedComponent(final int depth, final String finalText) {
340+
Component component = Component.text(finalText);
341+
342+
for (int i = 0; i < depth; i++) {
343+
component = Component.translatable("%1$s%1$s%1$s", component);
344+
}
345+
346+
return component;
347+
}
348+
349+
@Test
350+
void testGiantComponent() {
351+
final Component component = createNestedComponent(34, "only 34?!");
352+
final StringBuilder sb = new StringBuilder();
353+
final Exception exception = assertThrows(IllegalStateException.class, () -> PAPERS_WEIRD_FLATTENER.flatten(component, sb::append));
354+
assertTrue(exception.getMessage().startsWith("Exceeded maximum nesting depth"));
355+
}
291356
}

0 commit comments

Comments
 (0)