Skip to content

Commit abd2d81

Browse files
committed
new auto-completer 🎉
1 parent e4d4412 commit abd2d81

File tree

2 files changed

+283
-218
lines changed

2 files changed

+283
-218
lines changed
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
package revxrsal.commands.autocomplete;
2+
3+
import org.jetbrains.annotations.Contract;
4+
import org.jetbrains.annotations.NotNull;
5+
import org.jetbrains.annotations.Nullable;
6+
import revxrsal.commands.command.CommandActor;
7+
import revxrsal.commands.command.ExecutableCommand;
8+
import revxrsal.commands.node.*;
9+
import revxrsal.commands.stream.MutableStringStream;
10+
11+
import java.util.*;
12+
13+
import static revxrsal.commands.node.DispatcherSettings.LONG_FORMAT_PREFIX;
14+
import static revxrsal.commands.node.DispatcherSettings.SHORT_FORMAT_PREFIX;
15+
16+
/**
17+
* Auto-completer for individual {@link ExecutableCommand ExecutableCommands}
18+
*
19+
* @param <A> Actor type
20+
*/
21+
final class SingleCommandCompleter<A extends CommandActor> {
22+
23+
private final ExecutableCommand<A> command;
24+
private final MutableStringStream input;
25+
private final MutableExecutionContext<A> context;
26+
27+
private final List<String> suggestions = new ArrayList<>();
28+
29+
private int positionBeforeParsing = -1;
30+
31+
public SingleCommandCompleter(A actor, ExecutableCommand<A> command, MutableStringStream input) {
32+
this.command = command;
33+
this.input = input;
34+
this.context = ExecutionContext.createMutable(command, actor, input.toImmutableCopy());
35+
}
36+
37+
private void rememberPosition() {
38+
if (positionBeforeParsing != -1)
39+
throw new IllegalArgumentException("You already have a position remembered that you did not consume.");
40+
positionBeforeParsing = input.position();
41+
}
42+
43+
private String restorePosition() {
44+
if (positionBeforeParsing == -1)
45+
throw new IllegalArgumentException("You forgot to call rememberPosition() when trying to restore position.");
46+
int positionAfterParsing = input.position();
47+
input.setPosition(positionBeforeParsing);
48+
positionBeforeParsing = -1;
49+
return input.peek(positionAfterParsing - positionBeforeParsing);
50+
}
51+
52+
public void complete() {
53+
Map<String, ParameterNode<A, Object>> remainingFlags = null;
54+
for (CommandNode<A> node : command.nodes()) {
55+
if (node.isLiteral()) {
56+
CompletionResult result = completeLiteral(node.requireLiteralNode());
57+
if (result == CompletionResult.HALT)
58+
break;
59+
} else {
60+
ParameterNode<A, Object> parameter = node.requireParameterNode();
61+
if (parameter.isFlag() || parameter.isSwitch()) {
62+
(remainingFlags == null ? remainingFlags = new HashMap<>() : remainingFlags)
63+
.put(universalFlagName(parameter), parameter);
64+
continue;
65+
}
66+
CompletionResult result = completeParameter(parameter);
67+
if (result == CompletionResult.HALT)
68+
break;
69+
}
70+
}
71+
if (!command.containsFlags() || remainingFlags == null)
72+
return;
73+
completeFlags(remainingFlags);
74+
}
75+
76+
private CompletionResult completeParameter(@NotNull ParameterNode<A, Object> parameter) {
77+
rememberPosition();
78+
if (parameter.isSwitch()) {
79+
context.addResolvedArgument(parameter.name(), true);
80+
return CompletionResult.CONTINUE;
81+
}
82+
try {
83+
Object value = parameter.parse(input, context);
84+
context.addResolvedArgument(parameter.name(), value);
85+
int positionAfterParsing = input.position();
86+
String consumed = restorePosition();
87+
Collection<String> parameterSuggestions = parameter.complete(context.actor(), input, context);
88+
input.setPosition(positionAfterParsing); // restore so that we can move forward
89+
90+
if (input.hasFinished()) {
91+
filterSuggestions(consumed, parameterSuggestions);
92+
return CompletionResult.HALT;
93+
}
94+
if (input.peek() == ' ')
95+
input.skipWhitespace();
96+
return CompletionResult.CONTINUE;
97+
} catch (Throwable t) {
98+
String consumed = restorePosition();
99+
filterSuggestions(consumed, parameter.complete(context.actor(), input, context));
100+
return CompletionResult.HALT;
101+
}
102+
}
103+
104+
@Contract(mutates = "param1")
105+
private void completeFlags(@NotNull Map<String, ParameterNode<A, Object>> remainingFlags) {
106+
boolean lastWasShort = false;
107+
while (input.hasRemaining()) {
108+
if (input.peek() == ' ')
109+
input.skipWhitespace();
110+
String next = input.peekUnquotedString();
111+
if (next.startsWith("--")) {
112+
lastWasShort = false;
113+
String flagName = next.substring(LONG_FORMAT_PREFIX.length());
114+
ParameterNode<A, Object> targetFlag = remainingFlags.remove(flagName);
115+
if (targetFlag == null) {
116+
for (ParameterNode<A, Object> value : remainingFlags.values()) {
117+
if (universalFlagName(value).startsWith(flagName))
118+
suggestions.add(LONG_FORMAT_PREFIX + universalFlagName(value));
119+
}
120+
return;
121+
}
122+
input.readUnquotedString(); // consumes the flag name
123+
if (input.hasFinished())
124+
return;
125+
if (input.remaining() == 1 && input.peek() == ' ') {
126+
Collection<String> parameterSuggestions = targetFlag.complete(context.actor(), input, context);
127+
suggestions.addAll(parameterSuggestions);
128+
return;
129+
}
130+
input.skipWhitespace();
131+
CompletionResult result = completeParameter(targetFlag);
132+
if (result == CompletionResult.HALT) {
133+
break;
134+
} else if (input.hasRemaining() && input.peek() == ' ') {
135+
input.skipWhitespace();
136+
}
137+
} else if (next.startsWith("-")) {
138+
lastWasShort = true;
139+
String shortenedString = next.substring(SHORT_FORMAT_PREFIX.length());
140+
char[] spec = shortenedString.toCharArray();
141+
input.readUnquotedString();
142+
for (char flag : spec) {
143+
@Nullable ParameterNode<A, Object> targetFlag = removeParameterWithShorthand(remainingFlags, flag);
144+
if (targetFlag == null)
145+
continue;
146+
if (targetFlag.isSwitch()) {
147+
context.addResolvedArgument(targetFlag.name(), true);
148+
input.moveForward();
149+
continue;
150+
}
151+
if (input.hasFinished()) {
152+
for (ParameterNode<A, Object> remFlag : remainingFlags.values()) {
153+
if (remFlag.shorthand() != null) {
154+
String flagCompletion = SHORT_FORMAT_PREFIX + shortenedString + remFlag.shorthand();
155+
suggestions.add(remFlag.isFlag() ? flagCompletion + ' ' : flagCompletion);
156+
}
157+
}
158+
return;
159+
}
160+
if (input.remaining() == 1 && input.peek() == ' ') {
161+
Collection<String> parameterSuggestions = targetFlag.complete(context.actor(), input, context);
162+
suggestions.addAll(parameterSuggestions);
163+
return;
164+
}
165+
if (input.hasRemaining() && input.peek() == ' ')
166+
input.skipWhitespace();
167+
CompletionResult result = completeParameter(targetFlag);
168+
if (result == CompletionResult.HALT) {
169+
return;
170+
}
171+
172+
}
173+
}
174+
}
175+
for (ParameterNode<A, Object> c : remainingFlags.values()) {
176+
if (lastWasShort)
177+
suggestions.add(SHORT_FORMAT_PREFIX + c.shorthand());
178+
else
179+
suggestions.add(LONG_FORMAT_PREFIX + (c.isSwitch() ? c.switchName() : c.flagName()));
180+
}
181+
}
182+
183+
private @Nullable ParameterNode<A, Object> removeParameterWithShorthand(
184+
Map<String, ParameterNode<A, Object>> parametersLeft,
185+
char c
186+
) {
187+
for (Iterator<Map.Entry<String, ParameterNode<A, Object>>> iterator = parametersLeft.entrySet().iterator(); iterator.hasNext(); ) {
188+
Map.Entry<String, ParameterNode<A, Object>> entry = iterator.next();
189+
Character shorthand = entry.getValue().shorthand();
190+
if (shorthand != null && shorthand == c) {
191+
iterator.remove();
192+
return entry.getValue();
193+
}
194+
}
195+
return null;
196+
}
197+
198+
private CompletionResult completeLiteral(@NotNull LiteralNode<A> node) {
199+
String nextWord = input.readUnquotedString();
200+
if (input.hasFinished()) {
201+
if (node.name().startsWith(nextWord)) {
202+
// complete it for the user :)
203+
suggestions.add(node.name());
204+
}
205+
return CompletionResult.HALT;
206+
}
207+
if (!node.name().equalsIgnoreCase(nextWord)) {
208+
// the user inputted a command that isn't ours. dismiss the operation
209+
return CompletionResult.HALT;
210+
}
211+
if (input.hasRemaining() && input.peek() == ' ') {
212+
// our literal is just fine. move to the next node
213+
input.skipWhitespace();
214+
return CompletionResult.CONTINUE;
215+
}
216+
return CompletionResult.HALT;
217+
}
218+
219+
private void filterSuggestions(String consumed, @NotNull Collection<String> parameterSuggestions) {
220+
for (String parameterSuggestion : parameterSuggestions) {
221+
if (parameterSuggestion.toLowerCase().startsWith(consumed.toLowerCase())) {
222+
suggestions.add(getRemainingContent(parameterSuggestion, consumed));
223+
}
224+
}
225+
}
226+
227+
private String universalFlagName(@NotNull ParameterNode<A, Object> parameter) {
228+
if (parameter.isSwitch())
229+
return parameter.switchName();
230+
if (parameter.isFlag())
231+
return parameter.flagName();
232+
return parameter.name();
233+
}
234+
235+
public @NotNull List<String> suggestions() {
236+
return suggestions;
237+
}
238+
239+
/**
240+
* Represents the result of the completion of a {@link CommandNode}
241+
*/
242+
private enum CompletionResult {
243+
244+
/**
245+
* Halt the completion and don't return anything. This is sent when:
246+
* <ul>
247+
* <li>
248+
* When a node completes successfully
249+
* </li>
250+
* <li>
251+
* The command being completed is not ours (i.e. user is completing "foo"
252+
* but we're "bar")
253+
* </li>
254+
* <li>
255+
* A node fails to complete because it cannot parse the given input
256+
* </li>
257+
* </ul>
258+
*/
259+
HALT,
260+
261+
/**
262+
* Continue moving through the command nodes. This is sent when
263+
* all previous nodes have been successfully parsed, and the input
264+
* has been valid until now.
265+
*/
266+
CONTINUE
267+
}
268+
269+
private static String getRemainingContent(String suggestion, String consumed) {
270+
// Find the index where they match until
271+
int matchIndex = consumed.length();
272+
273+
// Find the first space after the matching part
274+
int spaceIndex = suggestion.lastIndexOf(' ', matchIndex - 1);
275+
276+
// Return the content after the first space
277+
return suggestion.substring(spaceIndex + 1);
278+
}
279+
280+
}

0 commit comments

Comments
 (0)