package me.desht.scrollingmenusign.parser;
import com.google.common.collect.Sets;
import me.desht.dhutils.Debugger;
import me.desht.dhutils.LogUtils;
import me.desht.dhutils.MiscUtil;
import me.desht.dhutils.PermissionUtils;
import me.desht.dhutils.cost.Cost;
import me.desht.scrollingmenusign.SMSException;
import me.desht.scrollingmenusign.ScrollingMenuSign;
import me.desht.scrollingmenusign.commandlets.BaseCommandlet;
import me.desht.scrollingmenusign.commandlets.CommandletManager;
import me.desht.scrollingmenusign.enums.ReturnStatus;
import me.desht.scrollingmenusign.util.Substitutions;
import me.desht.scrollingmenusign.variables.VariablesManager;
import me.desht.scrollingmenusign.views.CommandTrigger;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
public class ParsedCommand {
private String command;
private List<String> args;
private boolean elevated;
private boolean restricted;
private boolean affordable;
private boolean applicable;
private List<Cost> costs;
private ReturnStatus status;
private boolean whisper;
private boolean chat;
private boolean macro;
private boolean console;
private String lastError;
private StringBuilder rawCommand;
private String[] quotedArgs;
private boolean commandlet;
private StopCondition commandStopCondition;
private StopCondition macroStopCondition;
@Override
public String toString() {
return String.format("ParsedCommand [%s], el=%s re=%s af=%s ap=%s st=%s wh=%s ch=%s ma=%s co=%s",
command, elevated, restricted, affordable, applicable, status, whisper, chat, macro, console);
}
ParsedCommand(CommandSender sender, CommandTrigger trigger, Scanner scanner) throws SMSException {
args = new ArrayList<String>();
costs = new ArrayList<Cost>();
elevated = restricted = chat = whisper = macro = console = commandlet = false;
affordable = applicable = true;
command = null;
status = ReturnStatus.UNKNOWN;
lastError = "no error";
rawCommand = new StringBuilder();
commandStopCondition = StopCondition.NONE;
macroStopCondition = StopCondition.NONE;
CommandletManager cmdlets = ScrollingMenuSign.getInstance().getCommandletManager();
while (scanner.hasNext()) {
String token = scanner.next();
if (token.startsWith("\"") || token.startsWith("'")) {
// quoted string (single or double) - swallow all following tokens until a matching
// quotation mark is detected
String quote = token.substring(0, 1);
if (token.endsWith(quote)) {
token = token.substring(1, token.length() - 1);
} else {
try {
token = token.substring(1);
Pattern oldDelimiter = scanner.delimiter();
scanner.useDelimiter(quote);
token = token + scanner.next();
scanner.useDelimiter(oldDelimiter);
scanner.next(); // swallow the closing quote
} catch (NoSuchElementException e) {
LogUtils.warning("Detected mismatched quote in command [" + rawCommand + quote + token + "]");
throw new SMSException("Mismatched quote detected in command.");
}
}
rawCommand.append(quote).append(token).append(quote).append(" ");
if (command == null)
command = token;
else
args.add(quote + token + quote);
continue;
}
if (sender instanceof Player) {
Set<String> missing = Sets.newHashSet();
token = Substitutions.predefSubs((Player) sender, token, trigger, missing);
if (!missing.isEmpty()) {
String menuName = trigger == null ? "???" : trigger.getActiveMenu((Player) sender).getName();
for (String key : missing) {
LogUtils.warning("unknown replacement <" + key + "> in command [" + rawCommand + "...], menu " + menuName);
}
}
}
if (cmdlets.hasCommandlet(token) && command == null) {
// commandlet
command = token;
commandlet = true;
} else if (token.startsWith("%")) {
// macro
command = token.substring(1);
macro = true;
} else if ((token.startsWith("/@") || token.startsWith("/*")) && command == null) {
// elevated command
command = "/" + token.substring(2);
elevated = true;
} else if (token.startsWith("/#") && command == null) {
// console command
command = "/" + token.substring(2);
console = true;
} else if (token.startsWith("/") && command == null) {
// regular command
command = token;
} else if (token.startsWith("\\\\") && command == null) {
// a whisper string
command = token.substring(2);
whisper = true;
} else if (token.startsWith("\\") && command == null) {
// a chat string
command = token.substring(1);
chat = true;
} else if (token.startsWith("@!") && command == null) {
// verify a restriction NOT matched
if (restrictionCheck(sender, token.substring(2))) {
restricted = true;
}
} else if (token.startsWith("@") && command == null) {
// verify a restriction matched
if (!restrictionCheck(sender, token.substring(1))) {
restricted = true;
}
} else if (token.equals("$$$") && !restricted && affordable) {
// command terminator, and stop any macro too
commandStopCondition = StopCondition.ON_SUCCESS;
macroStopCondition = StopCondition.ON_SUCCESS;
break;
} else if (token.equals("$$")) {
// command terminator - run command and finish
commandStopCondition = StopCondition.ON_SUCCESS;
break;
} else if (token.startsWith("$") && command == null && sender instanceof Player) {
// apply a cost or costs
applyCosts((Player) sender, token);
} else if (token.equals("&&")) {
// command separator - start another command IF this command is runnable
commandStopCondition = StopCondition.ON_FAIL;
break;
} else {
// just a plain string
if (command == null)
command = token;
else
args.add(token);
}
rawCommand.append(token).append(" ");
}
List<String> strings = MiscUtil.splitQuotedString(rawCommand.toString());
quotedArgs = strings.toArray(new String[strings.size()]);
if (!(sender instanceof Player) && command != null && command.startsWith("/")) {
console = true;
}
Debugger.getInstance().debug(this.toString());
}
private void applyCosts(Player player, String token) {
for (String c : token.substring(1).split(";")) {
if (!c.isEmpty()) {
try {
Cost cost = Cost.parse(c);
costs.add(cost);
if (!cost.isAffordable(player)) {
affordable = false;
}
if (!cost.isApplicable(player)) {
applicable = false;
}
} catch (IllegalArgumentException e) {
throw new SMSException(e.getMessage() + ": bad cost");
}
}
}
}
public ParsedCommand(ReturnStatus rs, String message) {
status = rs;
lastError = message;
}
/**
* Get the name of the command, i.e. the first word of the command string
* with any special leading characters removed.
*
* @return The command name
*/
public String getCommand() {
return command;
}
/**
* Get the argument list for the command (excluding the command), split on
* whitespace.
*
* @return The command's arguments
*/
public List<String> getArgs() {
return args;
}
/**
* Get the argument list for the command (including the command), split on
* quoted substrings and/or whitespace.
*
* @return the argument list
*/
public String[] getQuotedArgs() {
return quotedArgs;
}
/**
* Get the elevation status, i.e. whether the command should be (has been)
* run with elevated permissions.
*
* @return true if elevated, false otherwise
*/
public boolean isElevated() {
return elevated;
}
/**
* Get the restriction status, i.e. whether the command will be (has been)
* ignored due to a restriction check not being met.
*
* @return true if the command was not run due to a restriction check, false otherwise
*/
public boolean isRestricted() {
return restricted;
}
/**
* Set the restriction status of this command.
*
* @param restricted whether or not this command should be considered restricted
*/
public void setRestricted(boolean restricted) {
this.restricted = restricted;
}
/**
* Get the affordable status, i.e. whether the command costs can be
* (have been) met by the player.
*
* @return true if the command is affordable, false otherwise
*/
public boolean isAffordable() {
return affordable;
}
/**
* Get the applicable status, i.e. whether the command costs actually make
* sense. E.g. repairing an item which doesn't have durability would not
* be applicable.
*
* @return true if the costs are applicable, false otherwise
*/
public boolean isApplicable() {
return applicable;
}
/**
* Check if this command is a special "commandlet" registered with SMS.
* See {@link BaseCommandlet}.
*
* @return true if this is a commandlet, false otherwise
*/
public boolean isCommandlet() {
return commandlet;
}
/**
* Get the details of the costs for this command.
*
* @return a List of Cost objects
*/
public List<Cost> getCosts() {
return costs;
}
/**
* Get the return status from actually running the command.
*
* @return the return status
*/
public ReturnStatus getStatus() {
return status;
}
/**
* Set the return status of this object.
*
* @param status the new return status
*/
void setStatus(ReturnStatus status) {
this.status = status;
}
/**
* Check if this command was to whisper a message to the player, i.e. it started with '\\'
*
* @return true if the command was a whisper, false otherwise
*/
public boolean isWhisper() {
return whisper;
}
/**
* Check if this command is a chat string, i.e. it started with '\'
*
* @return true if the command was a chat string, false otherwise
*/
public boolean isChat() {
return chat;
}
/**
* Check if this command calls a macro.
*
* @return true if a macro is used, false otherwise
*/
public boolean isMacro() {
return macro;
}
/**
* Check if the command sequence should be stopped, i.e. $$ or $$$
* was encountered following a command that actually ran (and was
* not ignored due to a restriction or cost check), or && was
* encountered following a command that did not run.
*
* @return true if the command was stopped, false otherwise
*/
public boolean isCommandStopped() {
switch (commandStopCondition) {
case NONE:
default:
return false;
case ON_FAIL:
return restricted || !affordable;
case ON_SUCCESS:
return !restricted && affordable;
}
}
/**
* Check if any enclosing macro should be stopped, i.e. $$ or $$$
* was encountered following a command that actually ran (and was
* not ignored due to a restriction or cost check), or && was
* encountered following a command that did not run.
*
* @return true if a macro was stopped, false otherwise
*/
public boolean isMacroStopped() {
switch (macroStopCondition) {
case NONE:
default:
return false;
case ON_FAIL:
return restricted || !affordable;
case ON_SUCCESS:
return !restricted && affordable;
}
}
/**
* Check if the command was run as a console command, i.e. started with '#'
*
* @return true if a console command, false otherwise
*/
public boolean isConsole() {
return console;
}
/**
* Get the last error message that was generated from running the command.
*
* @return The error text
*/
public String getLastError() {
return lastError;
}
void setLastError(String lastError) {
this.lastError = lastError;
}
public String getRawCommand() {
return rawCommand.toString().trim();
}
/**
* Get the argument at the given index.
*
* @param index Index of the argument to get
* @return The argument
*/
public String arg(int index) {
return args.get(index);
}
private boolean restrictionCheck(CommandSender sender, String check) {
if (!(sender instanceof Player)) {
// no restrictions apply when being run from the console
return true;
}
Player player = (Player) sender;
if (check.isEmpty()) {
return false;
}
String[] parts = check.split(":", 2);
if (parts.length == 1) {
// legacy check: just see if the player name matches
return player.getName().equalsIgnoreCase(parts[0]);
}
String checkType = parts[0];
String checkTerm = parts[1];
switch (checkType.charAt(0)) {
case 'g':
return ScrollingMenuSign.permission != null && ScrollingMenuSign.permission.playerInGroup(player, checkTerm);
case 'p':
return MiscUtil.looksLikeUUID(checkTerm) ? player.getUniqueId().equals(UUID.fromString(checkTerm)) : player.getName().equalsIgnoreCase(checkTerm);
case 'w':
return player.getWorld().getName().equalsIgnoreCase(checkTerm);
case 'n':
return PermissionUtils.isAllowedTo(player, checkTerm);
case 'i':
return isHoldingObject(player, checkTerm);
case 'v':
return variableTest(player, checkType, checkTerm);
default:
LogUtils.warning("Unknown check type: " + check);
return false;
}
}
private boolean isHoldingObject(Player player, String checkTerm) {
if (checkTerm.matches("^[0-9]+$")) {
LogUtils.warning("Checking for held items by ID is deprecated and will stop working in a future release.");
return player.getItemInHand().getTypeId() == Integer.parseInt(checkTerm);
} else {
Material mat = Material.matchMaterial(checkTerm);
if (mat == null) {
LogUtils.warning("Invalid material specification: " + checkTerm);
return false;
} else {
return player.getItemInHand().getType() == mat;
}
}
}
private static final Pattern exprPattern = Pattern.compile("^([a-zA-Z0-9\\._]+)(=|<|>|<=|>=)?(.+)?");
private boolean variableTest(Player player, String checkType, String checkTerm) {
Matcher m = exprPattern.matcher(checkTerm);
if (m.matches()) {
if (m.group(1) == null) {
return false;
} else if (m.group(2) == null) {
VariablesManager vm = ScrollingMenuSign.getInstance().getVariablesManager();
return vm.isSet(player, m.group(1));
} else {
return doComparison(player, checkType, m.group(1), m.group(2), m.group(3) == null ? "" : m.group(3));
}
}
return false;
}
private boolean doComparison(Player player, String checkType, String varSpec, String op, String testValue) {
VariablesManager vm = ScrollingMenuSign.getInstance().getVariablesManager();
String value = vm.get(player, varSpec);
if (value == null) {
return false;
}
boolean caseInsensitive = checkType.indexOf('i') > 0;
boolean useRegex = checkType.indexOf('r') > 0;
boolean forceNumeric = checkType.indexOf('n') > 0;
Debugger.getInstance().debug(2, "doComparison: player=[" + player.getDisplayName() + "] var=[" + varSpec + "] val=[" + value + "] op=[" + op + "] test=[" + testValue + "]");
Debugger.getInstance().debug(2, "doComparison: case-sensitive=" + !caseInsensitive + " regex=" + useRegex + " force-numeric=" + forceNumeric);
try {
if (op.equals("=")) {
if (useRegex) {
Pattern p = Pattern.compile(testValue, caseInsensitive ? Pattern.CASE_INSENSITIVE : 0);
return p.matcher(value).matches();
} else if (forceNumeric) {
return Double.parseDouble(value) == Double.parseDouble(testValue);
} else if (caseInsensitive) {
return value.equalsIgnoreCase(testValue);
} else {
return value.equals(testValue);
}
} else if (op.equals(">")) {
return Double.parseDouble(value) > Double.parseDouble(testValue);
} else if (op.equals("<")) {
return Double.parseDouble(value) < Double.parseDouble(testValue);
} else if (op.equals(">=")) {
return Double.parseDouble(value) >= Double.parseDouble(testValue);
} else if (op.equals("<=")) {
return Double.parseDouble(value) <= Double.parseDouble(testValue);
} else {
LogUtils.warning("unexpected comparison op: " + op);
}
} catch (NumberFormatException e) {
LogUtils.warning(e.getMessage() + ": invalid numeric value");
} catch (PatternSyntaxException e) {
LogUtils.warning("invalid regexp syntax: " + testValue + " " + e.getMessage());
}
return false;
}
public enum StopCondition {NONE, ON_SUCCESS, ON_FAIL}
}