diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/event/platform/CommandSuggestionEvent.java b/worldedit-core/src/main/java/com/sk89q/worldedit/event/platform/CommandSuggestionEvent.java index 6e1c92ccfd..452371f09c 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/event/platform/CommandSuggestionEvent.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/event/platform/CommandSuggestionEvent.java @@ -21,7 +21,7 @@ import com.sk89q.worldedit.event.Event; import com.sk89q.worldedit.extension.platform.Actor; -import com.sk89q.worldedit.internal.util.Substring; +import com.sk89q.worldedit.internal.command.FullStringSuggestion; import java.util.Collections; import java.util.List; @@ -35,7 +35,7 @@ public class CommandSuggestionEvent extends Event { private final Actor actor; private final String arguments; - private List suggestions = Collections.emptyList(); + private List suggestions = Collections.emptyList(); /** * Create a new instance. @@ -72,14 +72,9 @@ public String getArguments() { /** * Get the list of suggestions that are to be presented. * - *

- * Each Substring holds the replacement as the substring, - * and the replacement range as the original substring range. - *

- * * @return the list of suggestions */ - public List getSuggestions() { + public List getSuggestions() { return suggestions; } @@ -88,7 +83,7 @@ public List getSuggestions() { * * @param suggestions the list of suggestions */ - public void setSuggestions(List suggestions) { + public void setSuggestions(List suggestions) { checkNotNull(suggestions); this.suggestions = suggestions; } diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java b/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java index 5ec4350d27..8b53a1860a 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java @@ -97,14 +97,16 @@ import com.sk89q.worldedit.extent.Extent; import com.sk89q.worldedit.internal.annotation.OptionalArg; import com.sk89q.worldedit.internal.annotation.Selection; +import com.sk89q.worldedit.internal.command.CommandArgParseException; import com.sk89q.worldedit.internal.command.CommandArgParser; import com.sk89q.worldedit.internal.command.CommandLoggingHandler; import com.sk89q.worldedit.internal.command.CommandRegistrationHandler; +import com.sk89q.worldedit.internal.command.ExtractedArg; +import com.sk89q.worldedit.internal.command.FullStringSuggestion; import com.sk89q.worldedit.internal.command.exception.ExceptionConverter; import com.sk89q.worldedit.internal.command.exception.WorldEditExceptionConverter; import com.sk89q.worldedit.internal.util.ErrorReporting; import com.sk89q.worldedit.internal.util.LogManagerCompat; -import com.sk89q.worldedit.internal.util.Substring; import com.sk89q.worldedit.regions.Region; import com.sk89q.worldedit.session.request.Request; import com.sk89q.worldedit.util.eventbus.Subscribe; @@ -465,8 +467,11 @@ void removeCommands() { dynamicHandler.setHandler(null); } - private Stream parseArgs(String input) { - return CommandArgParser.forArgString(input.substring(1)).parseArgs(); + private Stream parseArgs(String input, boolean forCompletions) { + if (input.isEmpty() || input.charAt(0) != '/') { + throw new IllegalStateException("Command input must start with /: " + input); + } + return CommandArgParser.parse(input.substring(1), forCompletions); } @Subscribe @@ -474,12 +479,20 @@ public void handleCommand(CommandEvent event) { Request.reset(); Actor actor = platformManager.createProxyActor(event.getActor()); - String[] split = parseArgs(event.getArguments()) - .map(Substring::getSubstring) - .toArray(String[]::new); + List split; + try { + split = parseArgs(event.getArguments(), false) + .map(ExtractedArg::parsedText) + .collect(ImmutableList.toImmutableList()); + } catch (CommandArgParseException e) { + actor.printError(TranslatableComponent.of( + "worldedit.error.command-parse", TextComponent.of(e.getMessage()) + )); + return; + } // No command found! - if (!commandManager.containsCommand(split[0])) { + if (!commandManager.containsCommand(split.getFirst())) { return; } @@ -501,10 +514,10 @@ public void handleCommand(CommandEvent event) { // This is a bit of a hack, since the call method can only throw CommandExceptions // everything needs to be wrapped at least once. Which means to handle all WorldEdit // exceptions without writing a hook into every dispatcher, we need to unwrap these - // exceptions and rethrow their converted form, if their is one. + // exceptions and rethrow their converted form, if there is one. try { try { - commandManager.execute(context, ImmutableList.copyOf(split)); + commandManager.execute(context, split); } finally { Optional editSessionOpt = context.snapshotMemory().injectedValue(Key.of(EditSession.class)); @@ -605,14 +618,20 @@ private void handleUnknownException(Actor actor, Throwable t) { public void handleCommandSuggestion(CommandSuggestionEvent event) { try { String arguments = event.getArguments(); - List split = parseArgs(arguments).toList(); - List argStrings = split.stream() - .map(Substring::getSubstring) - .collect(Collectors.toList()); + List extractedArgs; + try { + extractedArgs = parseArgs(arguments, true).toList(); + } catch (CommandArgParseException e) { + LOGGER.debug("Failed to parse command arguments for suggestions: " + arguments, e); + return; + } + List extractedArgStrings = extractedArgs.stream() + .map(ExtractedArg::parsedText) + .collect(ImmutableList.toImmutableList()); MemoizingValueAccess access = initializeInjectedValues(() -> arguments, event.getActor()); ImmutableSet suggestions; try { - suggestions = commandManager.getSuggestions(access, argStrings); + suggestions = commandManager.getSuggestions(access, extractedArgStrings); } catch (Throwable t) { // catch errors which are *not* command exceptions generated by parsers/suggesters if (!(t instanceof CommandException)) { LOGGER.debug("Unexpected error occurred while generating suggestions for input: " + arguments, t); @@ -623,16 +642,20 @@ public void handleCommandSuggestion(CommandSuggestionEvent event) { event.setSuggestions(suggestions.stream() .map(suggestion -> { - int noSlashLength = arguments.length() - 1; - Substring original = suggestion.getReplacedArgument() == split.size() - ? Substring.from(arguments, noSlashLength, noSlashLength) - : split.get(suggestion.getReplacedArgument()); - // increase original points by 1, for removed `/` in `parseArgs` - return Substring.wrap( - suggestion.getSuggestion(), - original.getStart() + 1, - original.getEnd() + 1 - ); + String escapedText = escape(suggestion.getSuggestion()); + if (suggestion.getReplacedArgument() == extractedArgs.size()) { + // Argument addition, use end of string as target + // Also add a space on the front to cause it to be a new argument + return new FullStringSuggestion( + " " + escapedText, arguments.length(), arguments.length() + ); + } else { + ExtractedArg arg = extractedArgs.get(suggestion.getReplacedArgument()); + // +1 to compensate for the removal of / in parseArgs + return new FullStringSuggestion( + escapedText, arg.originalStart() + 1, arg.originalEnd() + 1 + ); + } }).collect(Collectors.toList())); } catch (ConditionFailedException e) { if (e.getCondition() instanceof PermissionCondition) { @@ -641,6 +664,28 @@ public void handleCommandSuggestion(CommandSuggestionEvent event) { } } + private static String escape(String suggestion) { + if (suggestion.contains(" ")) { + // It might be nice to do smart quoting, since we support both ' and " in arguments + // For now, I'm just doing the simple option + StringBuilder builder = new StringBuilder(suggestion.length() + 2); + builder.append('"'); + for (int i = 0; i < suggestion.length(); i++) { + char c = suggestion.charAt(i); + if (c == '\\') { + builder.append("\\\\"); + } else if (c == '"') { + builder.append("\\\""); + } else { + builder.append(c); + } + } + builder.append('"'); + return builder.toString(); + } + return suggestion; + } + /** * Get the command manager instance. * diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParseException.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParseException.java new file mode 100644 index 0000000000..9661424111 --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParseException.java @@ -0,0 +1,26 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.command; + +public class CommandArgParseException extends RuntimeException { + public CommandArgParseException(String message) { + super(message); + } +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParser.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParser.java index 092844b123..d5d560e7ec 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParser.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandArgParser.java @@ -19,113 +19,142 @@ package com.sk89q.worldedit.internal.command; -import com.google.common.base.Splitter; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; -import com.sk89q.worldedit.internal.util.Substring; +import com.google.common.collect.AbstractIterator; +import com.google.common.collect.Streams; +import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; import java.util.stream.Stream; -public class CommandArgParser { +/** + * Parser for command arguments. + * + *

Rules: + *

    + *
  • Arguments are separated by whitespace.
  • + *
  • Starting an argument with a quote character will start quote mode and include all characters until the next + * unescaped quote character. Backslash is the escape character, and only works for quotes and backslashes when + * in quote mode. Quote mode ends where the argument does. There must be whitespace following the unescaped quote + * character or an error will be thrown, and should be reported to the user.
  • + *
  • If {@code forCompletions}, quotes and escapes may be partial and end at the end of the string. + * This is required to do completions while in quotes. Partial escapes will just be ignored.
  • + *
+ */ +public class CommandArgParser extends AbstractIterator { - public static CommandArgParser forArgString(String argString) { - return new CommandArgParser(spaceSplit(argString)); + public static Stream parse(String argString, boolean forCompletions) { + return Streams.stream(new CommandArgParser(argString, forCompletions)); } - public static ImmutableList spaceSplit(String string) { - ImmutableList.Builder result = ImmutableList.builder(); - int index = 0; - for (String part : Splitter.on(' ').split(string)) { - result.add(Substring.from(string, index, index + part.length())); - index += part.length() + 1; - } - return result.build(); - } + private final String input; + private final boolean forCompletions; + private int index; - private enum State { - NORMAL, - QUOTE + private CommandArgParser(String input, boolean forCompletions) { + this.input = input; + this.forCompletions = forCompletions; } - private final Stream.Builder args = Stream.builder(); - private final List input; - private final List currentArg = new ArrayList<>(); - private int index = 0; - private State state = State.NORMAL; + @Override + protected @Nullable ExtractedArg computeNext() { + if (index > input.length()) { + return endOfData(); + } + if (index == input.length()) { + // May need to special case this to handle trailing whitespace or empty strings. Index is bumped + // to signal end. + index++; + if (input.isEmpty()) { + return new ExtractedArg("", 0, 0); + } + if (Character.isWhitespace(input.charAt(input.length() - 1))) { + return new ExtractedArg("", input.length(), input.length()); + } + return endOfData(); + } + char startChar = input.charAt(index); + return switch (startChar) { + case '\'', '"' -> finishQuoted(); + case '\\' -> { + // Check if escaping quote, if so we need to feed the unquoted reader some extra info. + if (index + 1 >= input.length()) { + if (forCompletions) { + index++; + yield new ExtractedArg("", index, index); + } + throw new CommandArgParseException("Invalid escape at end of string"); + } + char escaped = input.charAt(index + 1); + if (escaped == '\'' || escaped == '"') { + int startIndex = index; + index++; + yield finishUnquoted(startIndex); + } + // Otherwise, this is an error. + throw new CommandArgParseException("Invalid escaped character: " + escaped); + } + default -> finishUnquoted(index); + }; + } - public CommandArgParser(List input) { - this.input = input; + private char takeChar() { + char c = input.charAt(index); + index++; + return c; } - public Stream parseArgs() { - for (; index < input.size(); index++) { - Substring nextPart = input.get(index); - switch (state) { - case NORMAL: - handleNormal(nextPart); - break; - case QUOTE: - handleQuote(nextPart); - break; - default: + private ExtractedArg finishQuoted() { + int start = index; + char quoteChar = takeChar(); + StringBuilder builder = new StringBuilder(); + while (index < input.length()) { + char c = takeChar(); + if (c == '\\') { + if (index >= input.length()) { + // Error out. break; + } + char next = takeChar(); + if (next == quoteChar || next == '\\') { + builder.append(next); + } else { + throw new CommandArgParseException("Invalid escaped character: " + next); + } + continue; } + if (c == quoteChar) { + int end = index; + if (index < input.length() && !Character.isWhitespace(takeChar())) { + throw new CommandArgParseException("Expected whitespace after quote"); + } + return new ExtractedArg(builder.toString(), start, end); + } + builder.append(c); } - if (currentArg.size() > 0) { - finishArg(); // force finish "hanging" args + if (forCompletions) { + // Add an extra offset to the index to signal that we are done completely. + int end = index; + index++; + return new ExtractedArg(builder.toString(), start, end); } - return args.build(); + throw new CommandArgParseException("Unterminated quote"); } - private void handleNormal(Substring part) { - final String strPart = part.getSubstring(); - if (strPart.startsWith("\"")) { - if (strPart.endsWith("\"") && strPart.length() > 1) { - currentArg.add(Substring.wrap( - strPart.substring(1, strPart.length() - 1), - part.getStart() + 1, part.getEnd() - 1 - )); - finishArg(); - } else { - state = State.QUOTE; - currentArg.add(Substring.wrap( - strPart.substring(1), - part.getStart() + 1, part.getEnd() - )); + /** + * Finish reading an unquoted argument. + * + * @param startIndex the index at which the argument started, which may be behind the current index that the string + * should be copied from + * @return the extracted argument + */ + private ExtractedArg finishUnquoted(int startIndex) { + int start = index; + while (index < input.length()) { + char c = takeChar(); + if (Character.isWhitespace(c)) { + int end = index - 1; + return new ExtractedArg(input.substring(start, end), startIndex, end); } - } else { - currentArg.add(part); - finishArg(); - } - } - - private void handleQuote(Substring part) { - if (part.getSubstring().endsWith("\"")) { - state = State.NORMAL; - currentArg.add(Substring.wrap( - part.getSubstring().substring(0, part.getSubstring().length() - 1), - part.getStart(), part.getEnd() - 1 - )); - finishArg(); - } else { - currentArg.add(part); } + return new ExtractedArg(input.substring(start), startIndex, index); } - - private void finishArg() { - // Merge the arguments into a single, space-joined, string - // Keep the original start + end points. - int start = currentArg.get(0).getStart(); - int end = Iterables.getLast(currentArg).getEnd(); - args.add(Substring.wrap(currentArg.stream() - .map(Substring::getSubstring) - .collect(Collectors.joining(" ")), - start, end - )); - currentArg.clear(); - } - } diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandUtil.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandUtil.java index 61b03375cd..c3641709e1 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandUtil.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/CommandUtil.java @@ -20,10 +20,8 @@ package com.sk89q.worldedit.internal.command; import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; import com.sk89q.worldedit.extension.platform.Actor; import com.sk89q.worldedit.extension.platform.PlatformCommandManager; -import com.sk89q.worldedit.internal.util.Substring; import com.sk89q.worldedit.util.formatting.text.Component; import com.sk89q.worldedit.util.formatting.text.TextComponent; import com.sk89q.worldedit.util.formatting.text.event.ClickEvent; @@ -45,7 +43,6 @@ import java.util.function.Predicate; import java.util.stream.Collectors; -import static com.google.common.base.Preconditions.checkState; import static java.util.stream.Collectors.toList; public class CommandUtil { @@ -206,58 +203,51 @@ public static Comparator byCleanName() { } /** - * Fix {@code suggestions} to replace the last space-separated word in {@code arguments}. - */ - public static List fixSuggestions(String arguments, List suggestions) { - Substring lastArg = Iterables.getLast( - CommandArgParser.spaceSplit(arguments) - ); - return suggestions.stream() - // Re-map suggestions to only operate on the last non-quoted word - .map(suggestion -> onlyOnLastQuotedWord(lastArg, suggestion)) - .map(suggestion -> suggestLast(lastArg, suggestion)) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(toList()); - } - - private static Substring onlyOnLastQuotedWord(Substring lastArg, Substring suggestion) { - if (suggestion.getSubstring().startsWith(lastArg.getSubstring())) { - // This is already fine. - return suggestion; - } - String substr = suggestion.getSubstring(); - // Check if there is a space inside the substring, and suggest starting from there instead. - int sp = substr.trim().lastIndexOf(' '); - if (sp < 0) { - return suggestion; - } - return Substring.wrap(substr.substring(sp + 1), suggestion.getStart() + sp + 1, suggestion.getEnd()); - } - - /** - * Given the last word of a command, mutate the suggestion to replace the last word, if - * possible. + * Fix suggestions to only replace the last space-separated argument from {@code arguments}. + * + * @param arguments the full command string, e.g. {@code "/command arg1 arg2"} + * @param suggestions the suggestions + * @return a list of fixed suggestions */ - private static Optional suggestLast(Substring last, Substring suggestion) { - if (suggestion.getStart() == last.getEnd() && !last.getSubstring().equals("\"")) { - // this suggestion is for the next argument. - if (last.getSubstring().isEmpty()) { - return Optional.of(suggestion.getSubstring()); - } - return Optional.of(last.getSubstring() + " " + suggestion.getSubstring()); - } - StringBuilder builder = new StringBuilder(last.getSubstring()); - int start = suggestion.getStart() - last.getStart(); - int end = suggestion.getEnd() - last.getStart(); - if (start < 0) { - // Quoted suggestion, can't complete it here. - return Optional.empty(); + public static List fixSuggestions(String arguments, List suggestions) { + int minIndex = arguments.lastIndexOf(' ') + 1; + if (minIndex == 0) { + // No space, so no suggestions + return List.of(); } - checkState(end <= builder.length(), - "Suggestion ends too late, last=%s, suggestion=", last, suggestion); - builder.replace(start, end, suggestion.getSubstring()); - return Optional.of(builder.toString()); + return suggestions.stream() + .mapMulti((fullStringSuggestion, downstream) -> { + if (fullStringSuggestion.replaceStart() < minIndex) { + if (fullStringSuggestion.replaceEnd() <= minIndex) { + // This suggestion is entirely before the last argument, so ignore it + return; + } + // We might still take this suggestion if the text matches up to minIndex + int fromIndex = fullStringSuggestion.replaceStart(); + int len = minIndex - fromIndex; + if (!arguments.regionMatches( + fromIndex, fullStringSuggestion.escapedText(), 0, len + )) { + // This suggestion changes the text before the last argument, so ignore it + return; + } + // Adjust the suggestion so it's only for after minIndex + fullStringSuggestion = new FullStringSuggestion( + fullStringSuggestion.escapedText().substring(len), + fullStringSuggestion.replaceStart() + len, + fullStringSuggestion.replaceEnd() + ); + } + // Tweak the suggestion so it replaces the last argument entirely. + StringBuilder adjusted = new StringBuilder(arguments); + adjusted.replace( + fullStringSuggestion.replaceStart(), + fullStringSuggestion.replaceEnd(), + fullStringSuggestion.escapedText() + ); + downstream.accept(adjusted.substring(minIndex)); + }) + .toList(); } /** diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/ExtractedArg.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/ExtractedArg.java new file mode 100644 index 0000000000..7561360cf7 --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/ExtractedArg.java @@ -0,0 +1,39 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.command; + +/** + * Argument extracted from a command string. + * + *

+ * The original start and end may combine to form a length longer than the parsed text. This is because quotes and + * escapes are removed from the parsed text. Suggestions replacing such arguments must be handled with care. + *

+ * + * @param parsedText the parsed text, with escapes removed and no quotes wrapping originally quoted text + * @param originalStart the start index of the argument in the original string, which may be the index of a quote + * @param originalEnd the end index of the argument in the original string, which may be the index of a quote + */ +public record ExtractedArg( + String parsedText, + int originalStart, + int originalEnd +) { +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/FullStringSuggestion.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/FullStringSuggestion.java new file mode 100644 index 0000000000..6e3f0f8eb6 --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/command/FullStringSuggestion.java @@ -0,0 +1,34 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.command; + +/** + * WorldEdit's version of {@link org.enginehub.piston.suggestion.Suggestion} for use with a single command string. + * + * @param escapedText the suggestion text, fully escaped and ready to be inserted into the command + * @param replaceStart the start index of the substring to replace + * @param replaceEnd the end index of the substring to replace + */ +public record FullStringSuggestion( + String escapedText, + int replaceStart, + int replaceEnd +) { +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/util/Substring.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/util/Substring.java deleted file mode 100644 index fd8514edc7..0000000000 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/util/Substring.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * WorldEdit, a Minecraft world manipulation toolkit - * Copyright (C) sk89q - * Copyright (C) WorldEdit team and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sk89q.worldedit.internal.util; - -import java.util.Objects; - -import static com.google.common.base.Preconditions.checkArgument; - -/** - * An explicit substring. Provides the range from which it was taken. - */ -public final class Substring { - - /** - * Take a substring from {@code original}, and {@link #wrap(String, int, int)} it into - * a Substring. - */ - public static Substring from(String original, int start) { - return new Substring(original.substring(start), start, original.length()); - } - - /** - * Take a substring from {@code original}, and {@link #wrap(String, int, int)} it into - * a Substring. - */ - public static Substring from(String original, int start, int end) { - return new Substring(original.substring(start, end), start, end); - } - - /** - * Wrap the given parameters into a Substring instance. - */ - public static Substring wrap(String substring, int start, int end) { - checkArgument(0 <= start, "Start must be greater than or equal to zero"); - checkArgument(start <= end, "End must be greater than or equal to start"); - return new Substring(substring, start, end); - } - - private final String substring; - private final int start; - private final int end; - - private Substring(String substring, int start, int end) { - this.substring = substring; - this.start = start; - this.end = end; - } - - public String getSubstring() { - return substring; - } - - public int getStart() { - return start; - } - - public int getEnd() { - return end; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Substring substring1 = (Substring) o; - return start == substring1.start - && end == substring1.end - && substring.equals(substring1.substring); - } - - @Override - public int hashCode() { - return Objects.hash(substring, start, end); - } - - @Override - public String toString() { - return "Substring{" - + "substring='" + substring + "'" - + ",start=" + start - + ",end=" + end - + "}"; - } -} diff --git a/worldedit-core/src/main/resources/lang/strings.json b/worldedit-core/src/main/resources/lang/strings.json index 54438bbb71..f80e9b300e 100644 --- a/worldedit-core/src/main/resources/lang/strings.json +++ b/worldedit-core/src/main/resources/lang/strings.json @@ -287,6 +287,7 @@ "worldedit.command.permissions": "You are not permitted to do that. Are you in the right mode?", "worldedit.command.player-only": "This command must be used with a player.", "worldedit.command.error.report": "Please report this error: [See console]", + "worldedit.command.parse-error": "Argument parsing failed: {0}", "worldedit.pastebin.uploading": "(Please wait... sending output to pastebin...)", "worldedit.session.cant-find-session": "Unable to find session for {0}", "worldedit.platform.no-file-dialog": "File dialogs are not supported in your environment.", diff --git a/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserForCompletionsTest.java b/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserForCompletionsTest.java new file mode 100644 index 0000000000..5f99e3a2b1 --- /dev/null +++ b/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserForCompletionsTest.java @@ -0,0 +1,145 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.command; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CommandArgParserForCompletionsTest { + + private static List argParse(String s) { + return CommandArgParser.parse(s, true).toList(); + } + + @Test + void testArgumentParsing() { + assertEquals(ImmutableList.of( + new ExtractedArg("", 0, 0) + ), argParse("")); + assertEquals(ImmutableList.of( + new ExtractedArg("ab", 0, 2) + ), argParse("ab")); + assertEquals(ImmutableList.of( + new ExtractedArg("", 0, 0), + new ExtractedArg("", 1, 1) + ), argParse(" ")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("", 2, 2) + ), argParse("a ")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b", 2, 3) + ), argParse("a b")); + } + + @ParameterizedTest + @ValueSource(chars = {'"', '\''}) + void testQuotes(char quote) { + assertEquals(ImmutableList.of( + new ExtractedArg("", 0, 2) + ), argParse(quote + "" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 3) + ), argParse(quote + "a" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg(" a ", 0, 5) + ), argParse(quote + " a " + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a ", 0, 4), + new ExtractedArg("", 5, 5) + ), argParse(quote + "a " + quote + " ")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 3), + new ExtractedArg("b", 4, 5) + ), argParse(quote + "a" + quote + " b")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b", 2, 5) + ), argParse("a " + quote + "b" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b", 2, 5), + new ExtractedArg("c", 6, 7) + ), argParse("a " + quote + "b" + quote + " c")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("something" + quote + "quotable" + quote + "here", 2, 25), + new ExtractedArg("c", 26, 27) + ), argParse("a something" + quote + "quotable" + quote + "here c")); + } + + @ParameterizedTest + @ValueSource(chars = {'"', '\''}) + void testPartialQuotes(char quote) { + // In 'for completions' mode, we should always return a result. + assertEquals(ImmutableList.of( + new ExtractedArg("", 0, 1) + ), argParse(quote + "")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 2) + ), argParse(quote + "a")); + assertEquals(ImmutableList.of( + new ExtractedArg("a ", 0, 3) + ), argParse(quote + "a ")); + assertEquals(ImmutableList.of( + new ExtractedArg("a b", 0, 4) + ), argParse(quote + "a b")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("", 2, 3) + ), argParse("a " + quote)); + // Mid quotes are fine. + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b" + quote + "c", 2, 5) + ), argParse("a b" + quote + "c")); + // End quotes are fine. + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b" + quote, 2, 4) + ), argParse("a b" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b" + quote, 2, 4), + new ExtractedArg("c", 5, 6) + ), argParse("a b" + quote + " c")); + } + + @ParameterizedTest + @ValueSource(chars = {'"', '\''}) + void testEscapingQuotes(char quote) { + assertEquals(ImmutableList.of( + new ExtractedArg(quote + "", 0, 2) + ), argParse("\\" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg(quote + "a", 0, 3) + ), argParse("\\" + quote + "a")); + assertEquals(ImmutableList.of( + new ExtractedArg(quote + "a spaced out arg" + quote, 0, 22) + ), argParse(quote + "\\" + quote + "a spaced out arg" + "\\" + quote + quote)); + } +} diff --git a/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserTest.java b/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserTest.java index 137613caf7..570d5ece68 100644 --- a/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserTest.java +++ b/worldedit-core/src/test/java/com/sk89q/worldedit/internal/command/CommandArgParserTest.java @@ -20,39 +20,120 @@ package com.sk89q.worldedit.internal.command; import com.google.common.collect.ImmutableList; -import com.sk89q.worldedit.internal.util.Substring; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.util.List; -import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; class CommandArgParserTest { - private static List argParse(String s) { - return CommandArgParser.forArgString(s).parseArgs().collect(Collectors.toList()); + private static List argParse(String s) { + return CommandArgParser.parse(s, false).toList(); + } + + private static void assertThrowsUnterminatedQuote(Runnable runnable) { + var ex = assertThrowsExactly(CommandArgParseException.class, runnable::run); + assertEquals("Unterminated quote", ex.getMessage()); } @Test void testArgumentParsing() { assertEquals(ImmutableList.of( - Substring.wrap("", 0, 0) + new ExtractedArg("", 0, 0) ), argParse("")); assertEquals(ImmutableList.of( - Substring.wrap("ab", 0, 2) + new ExtractedArg("ab", 0, 2) ), argParse("ab")); assertEquals(ImmutableList.of( - Substring.wrap("", 0, 0), - Substring.wrap("", 1, 1) + new ExtractedArg("", 0, 0), + new ExtractedArg("", 1, 1) ), argParse(" ")); assertEquals(ImmutableList.of( - Substring.wrap("a", 0, 1), - Substring.wrap("", 2, 2) + new ExtractedArg("a", 0, 1), + new ExtractedArg("", 2, 2) ), argParse("a ")); assertEquals(ImmutableList.of( - Substring.wrap("a", 0, 1), - Substring.wrap("b", 2, 3) + new ExtractedArg("a", 0, 1), + new ExtractedArg("b", 2, 3) ), argParse("a b")); } + + @ParameterizedTest + @ValueSource(chars = {'"', '\''}) + void testQuotes(char quote) { + assertEquals(ImmutableList.of( + new ExtractedArg("", 0, 2) + ), argParse(quote + "" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 3) + ), argParse(quote + "a" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg(" a ", 0, 5) + ), argParse(quote + " a " + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a ", 0, 4), + new ExtractedArg("", 5, 5) + ), argParse(quote + "a " + quote + " ")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 3), + new ExtractedArg("b", 4, 5) + ), argParse(quote + "a" + quote + " b")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b", 2, 5) + ), argParse("a " + quote + "b" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b", 2, 5), + new ExtractedArg("c", 6, 7) + ), argParse("a " + quote + "b" + quote + " c")); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("something" + quote + "quotable" + quote + "here", 2, 25), + new ExtractedArg("c", 26, 27) + ), argParse("a something" + quote + "quotable" + quote + "here c")); + } + + @ParameterizedTest + @ValueSource(chars = {'"', '\''}) + void testPartialQuotes(char quote) { + assertThrowsUnterminatedQuote(() -> argParse(quote + "")); + assertThrowsUnterminatedQuote(() -> argParse(quote + "a")); + assertThrowsUnterminatedQuote(() -> argParse(quote + "a ")); + assertThrowsUnterminatedQuote(() -> argParse(quote + "a b")); + assertThrowsUnterminatedQuote(() -> argParse("a " + quote)); + // Mid quotes are fine. + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b" + quote + "c", 2, 5) + ), argParse("a b" + quote + "c")); + // End quotes are fine. + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b" + quote, 2, 4) + ), argParse("a b" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg("a", 0, 1), + new ExtractedArg("b" + quote, 2, 4), + new ExtractedArg("c", 5, 6) + ), argParse("a b" + quote + " c")); + } + + @ParameterizedTest + @ValueSource(chars = {'"', '\''}) + void testEscapingQuotes(char quote) { + assertEquals(ImmutableList.of( + new ExtractedArg(quote + "", 0, 2) + ), argParse("\\" + quote)); + assertEquals(ImmutableList.of( + new ExtractedArg(quote + "a", 0, 3) + ), argParse("\\" + quote + "a")); + assertEquals(ImmutableList.of( + new ExtractedArg(quote + "a spaced out arg" + quote, 0, 22) + ), argParse(quote + "\\" + quote + "a spaced out arg" + "\\" + quote + quote)); + } } diff --git a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/CommandWrapper.java b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/CommandWrapper.java index e782a870de..f7747146e8 100644 --- a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/CommandWrapper.java +++ b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/CommandWrapper.java @@ -26,14 +26,13 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.context.StringRange; -import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestion; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.event.platform.CommandSuggestionEvent; import com.sk89q.worldedit.extension.platform.Actor; -import com.sk89q.worldedit.internal.util.Substring; +import com.sk89q.worldedit.internal.command.FullStringSuggestion; import net.minecraft.commands.CommandSourceStack; import org.enginehub.piston.inject.InjectedValueStore; import org.enginehub.piston.inject.Key; @@ -88,29 +87,20 @@ private static Predicate requirementsFor(org.enginehub.pisto } private static CompletableFuture suggest(CommandContext context, - SuggestionsBuilder builder) throws CommandSyntaxException { + SuggestionsBuilder builder) { CommandSuggestionEvent event = new CommandSuggestionEvent( FabricAdapter.adaptCommandSource(context.getSource()), builder.getInput() ); WorldEdit.getInstance().getEventBus().post(event); - List suggestions = event.getSuggestions(); - - ImmutableList.Builder result = ImmutableList.builder(); - - for (Substring suggestion : suggestions) { - String suggestionText = suggestion.getSubstring(); - // If at end, we are actually suggesting the next argument - // Ensure there is a space! - if (suggestion.getStart() == suggestion.getEnd() - && suggestion.getEnd() == builder.getInput().length() - && !builder.getInput().endsWith(" ") - && !builder.getInput().endsWith("\"")) { - suggestionText = " " + suggestionText; - } + List suggestions = event.getSuggestions(); + + ImmutableList.Builder result = ImmutableList.builderWithExpectedSize(suggestions.size()); + + for (FullStringSuggestion suggestion : suggestions) { result.add(new Suggestion( - StringRange.between(suggestion.getStart(), suggestion.getEnd()), - suggestionText + StringRange.between(suggestion.replaceStart(), suggestion.replaceEnd()), + suggestion.escapedText() )); } diff --git a/worldedit-neoforge/src/main/java/com/sk89q/worldedit/neoforge/CommandWrapper.java b/worldedit-neoforge/src/main/java/com/sk89q/worldedit/neoforge/CommandWrapper.java index c5f51132fe..b561b43607 100644 --- a/worldedit-neoforge/src/main/java/com/sk89q/worldedit/neoforge/CommandWrapper.java +++ b/worldedit-neoforge/src/main/java/com/sk89q/worldedit/neoforge/CommandWrapper.java @@ -26,14 +26,13 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.context.StringRange; -import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestion; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.event.platform.CommandSuggestionEvent; import com.sk89q.worldedit.extension.platform.Actor; -import com.sk89q.worldedit.internal.util.Substring; +import com.sk89q.worldedit.internal.command.FullStringSuggestion; import net.minecraft.commands.CommandSourceStack; import org.enginehub.piston.inject.InjectedValueStore; import org.enginehub.piston.inject.Key; @@ -83,29 +82,20 @@ private static Predicate requirementsFor(org.enginehub.pisto } private static CompletableFuture suggest(CommandContext context, - SuggestionsBuilder builder) throws CommandSyntaxException { + SuggestionsBuilder builder) { CommandSuggestionEvent event = new CommandSuggestionEvent( NeoForgeAdapter.adaptCommandSource(context.getSource()), builder.getInput() ); WorldEdit.getInstance().getEventBus().post(event); - List suggestions = event.getSuggestions(); + List suggestions = event.getSuggestions(); - ImmutableList.Builder result = ImmutableList.builder(); + ImmutableList.Builder result = ImmutableList.builderWithExpectedSize(suggestions.size()); - for (Substring suggestion : suggestions) { - String suggestionText = suggestion.getSubstring(); - // If at end, we are actually suggesting the next argument - // Ensure there is a space! - if (suggestion.getStart() == suggestion.getEnd() - && suggestion.getEnd() == builder.getInput().length() - && !builder.getInput().endsWith(" ") - && !builder.getInput().endsWith("\"")) { - suggestionText = " " + suggestionText; - } + for (FullStringSuggestion suggestion : suggestions) { result.add(new Suggestion( - StringRange.between(suggestion.getStart(), suggestion.getEnd()), - suggestionText + StringRange.between(suggestion.replaceStart(), suggestion.replaceEnd()), + suggestion.escapedText() )); }