Skip to content

Argument Parsing

Petrus Pradella edited this page Jul 31, 2026 · 10 revisions

Argument Parsing

What this page covers: how a command method's parameters become typed, validated arguments. Where a value comes from is decided by its annotation, never by syntax buried in a string. This page is the companion to Command Framework.


Where a parameter's value comes from

@FinalCMD.SubCMD(subcmd = "coins")
public void coins(FCommandSender sender, @Arg(name = "<amount>") Integer amount) { ... }
Form Provenance
no annotation the command context - the caller (sender, label, raw args, ...). It never consumes a typed token.
@Arg a token of the line, in this executable's window
@FlagArg a --name value flag, anywhere after the command path - see Flags
@FinalCMD.Captured what an ancestor node captured - see Command Framework

So an unannotated FPlayer is always whoever typed the command, and a target is always annotated. That is what spares the framework a wrapper type whose only job would be telling the two apart.


@Arg - positional arguments

public @interface Arg {
    String name();                                    // display name incl. brackets, e.g. "<amount>"
    String context() default "";                      // parser-specific constraint (see below)
    boolean fromSender() default false;               // resolve from the sender when no token was typed
    Class<? extends ArgParser> parser() default ArgParser.class; // override the type-based parser
    FCLocale[] locales() default {};                  // localized error text for this arg
    String def() default "";                          // declarative default for an OPTIONAL arg (see below)
}

Required vs nullable - the brackets in name

The brackets around the name decide whether the argument is required. This is parsed from the name string itself, and there are exactly two forms:

Form Meaning If the player omits it
<name> Required Parsing fails, the arg's error message is shown, the command aborts.
[name] Nullable / optional The parameter is injected as null; your method handles it.
// Required: "/heal <target>" - target must be given.
public void heal(FCommandSender s, @Arg(name = "<target>") FPlayer target) { ... }

// Optional: "/heal [target]" - null target means "heal myself".
public void heal(FCommandSender s, @Arg(name = "[target]") FPlayer target) {
    FPlayer who = target != null ? target : (FPlayer) s;
    // ...
}

⚠️ Gotcha - an optional [name] argument is injected as null when omitted. Always null-check optional arguments before dereferencing them.

fromSender - one method for "here" and "there"

Where a value comes from is always an annotation, never syntax buried in the displayed name. fromSender = true lets the parser answer from the sender's own state when the position was left empty, so one method serves both /arena enable true (the arena the admin is standing in) and /arena enable true myarena (the named one):

public void enable(FCommandSender s,
                   @Arg(name = "<enabled>") Boolean enabled,
                   @Arg(name = "<arena>", fromSender = true) Arena arena) { ... }

Two rules follow from it:

  • the parser has to override fromSender(ParseCall). One that does not is refused at registration - otherwise the argument would silently eat the next token instead of being inferred;
  • it fires only when that position is empty, so an inferred argument has to be the last positional (or optional). It does not hand the token back to the argument after it.

The variadic tail - <rest...>

A ... suffix inside the brackets makes the argument the tail: it takes every token left. It has to be the method's last @Arg, and there is at most one. Four parameter types are accepted, and they are four ways of handing over the same tokens:

Type What you get
String the tokens joined by a single space
String[] one entry per token
Argumento the joined text as the raw token type
MultiArgumentos the tokens as an argument list
@FinalCMD.SubCMD(subcmd = "broadcast")
public void broadcast(FCommandSender s, @Arg(name = "<message...>") String message) { ... }

A required tail with zero tokens sends the help line; an optional [rest...] with zero tokens hands over an empty value of its type. The tail never goes through an ArgParser - choosing among four shapes of the same tokens is the framework's job - but it still shows on the usage line and still tab-completes (the tail's parser keeps answering for every position it covers).

def() - a declarative default for an optional argument

def() is only legal on an [optional] argument - declaring it on a <required> one fails at registration, not at dispatch. When the player omits the argument (including typing an explicit empty string), the def() text is parsed by the exact same ArgParser the typed value would have gone through - context bounds and choices apply to it too:

// Omitted -> parsed as if the player had typed "1" - so it still respects the [1:*] bound.
@Arg(name = "[page]", context = "[1:*]", def = "1") Integer page

A value the player actually types always wins over def().

A def() the parser cannot use is the command's bug, never the sender's - that text was written by whoever declared the argument, so it does not take the silent degradation an optional argument gives a token nobody recognized. Either way it can fail - unreadable for the type, or refused by a context bound or choice - the dispatch aborts, the stack and the offending default go to the owning plugin's log, and whoever typed the command gets a generic line instead of being told to fix a value they never typed.

def() also supports the %placeholder% mechanism ICustomFinalCMD.customize() resolves for the rest of ArgData (name, context, locales).

context - constraining an argument

context narrows what the parser accepts, and its meaning is parser-specific. Every built-in parser shares the same small mini-DSL:

Type Form Meaning
Integer/Float/Double "min:max" (e.g. "1:10", "[1:*]") An inclusive numeric range. * on either side means unbounded in that direction. Wrapping the whole thing in the arg's own <...>/[...] brackets is conventional but not required - the parser strips a leading/trailing bracket pair if present, and reads the value unchanged either way.
Integer/Float/Double "a|b|c" (e.g. "1|5|10") A fixed set of accepted numeric values.
String "a|b|c" (e.g. "add|remove|list") A fixed, case-insensitive option set, also used for tab-complete. Empty context falls back to the arg's own name (so a plain <mode> with no context just means "any single token").
Enum (any) "A|B|C" A subset of the enum's constants (matched case-insensitively by their name()). Empty context means every constant (capped at 50 - ArgParserEnum throws past that, by design: that many choices belongs in a different UI).
Boolean "yes|no" (exactly 2 options) A custom true/false pair instead of the default true/false tokens (first option = true, second = false). A custom-context Boolean must have exactly 2 options - it fails at registration otherwise.
// A fixed option set (String parser): only these three tokens accepted, and tab-completed.
@Arg(name = "<mode>", context = "add|remove|list") String mode

// A numeric range (Number parser): page >= 1.
@Arg(name = "[page]", context = "[1:*]") Integer page

The same DSL applies to a @FlagArg's context(), applied to the flag's value parser - see Flags.


Built-in argument types

These types resolve out of the box - just declare the parameter type (positional needs @Arg):

Type Notes
String Free text, or a fixed option set via context.
Integer, Float, Double Numbers, with optional [min:max] range context.
Boolean true/false (and locale-aware variants).
Enum (any) Any enum; defaults to all constants as options.
UUID Parsed UUID.
FPlayer Portable online player (both platforms).
IPlayerData Resolves a player's data by name.
NumberWrapper EverNifeCore's arbitrary-precision number.
FCTimeFrame A duration like 10m, 2h, 3d.
Argumento The raw single token, unparsed.
PageVisualization A page number for a FancyText PageViewer.

On the Minecraft platform these are registered additionally (lazily, see below): Player and World, plus OreDictEntry (on modded servers) and FCWorldGuardRegion (when WorldGuard is present).


Contextual parameters (auto-injected)

A parameter with no @Arg is filled from the command context by an ArgParserContextual. The framework knows these types out of the box:

Type Injected value
FCommandSender The command sender (portable).
FPlayer The sender as a player (command becomes player-only).
String The command label the player typed.
MultiArgumentos The raw arguments of THIS method's window (see below).
HelpLine This sub-command's help line, already rendered for the path typed, to sendTo(sender) on bad input.
HelpContext The command's full help context.
CommandPath The concrete path this dispatch walked - label, literals and captured tokens.
PlayerData The sender's player data.
PDSection A specific PlayerData section of the sender.

On Minecraft you can additionally inject the native CommandSender, Player, and ItemStack (the item in hand).

⚠️ HelpLine is one render of a HelpLineTemplate - the template is the shared, path-less object the framework builds at registration, and sendTo only exists on the render. Inject HelpLine.

MultiArgumentos is the escape hatch when you want to parse the raw tokens yourself. It is the window this executable owns: index 0 is the first token after the command path, not after the label. For a leaf three levels down, /mycmd user Steve set a b, getStringArg(0) is a:

@FinalCMD.SubCMD(subcmd = "set", usage = "<PluginName> <LocaleName>")
public void set(FCommandSender sender, MultiArgumentos argumentos, HelpLine helpLine) {
    if (argumentos.emptyArgs(0, 1)) { helpLine.sendTo(sender); return; }
    String pluginName = argumentos.getStringArg(0);
    // ...
}

The same holds for ArgInfo.getIndex() inside a parser: the index is local to the window, so a parser never has to know how deep its command sits.


Writing a custom ArgParser

To make a parameter of your own type resolvable, extend ArgParser<T>. It needs an (ArgInfo) constructor and one method per job - the framework decides which one runs, and when:

Method Answers Called when
parse(ParseCall) what the token converted to, or why it did not there is a token, or a def() stands in for one
absent(ParseCall) the value of an optional argument nobody typed no token, optional, no def()
fromSender(ParseCall) the value read off the sender no token, @Arg(fromSender = true)

Only parse is abstract, and all three return a ParseResult<T> - never a bare value, never null, and never a thrown "no". The point of the split is that what a failure costs is the framework's decision, and only the text of it is the parser's - a parser that had to ask argInfo.isRequired() was rewriting the framework's decision tree in every implementation.

ParseResult - the six things a parse can answer

Kind Built with What the framework does
VALUE ParseResult.of(value) hands it to the method. Refuses null - say empty() instead
EMPTY ParseResult.empty() the parameter gets null and the invocation goes on
UNRECOGNIZED unrecognized(reason...) fatal only if the argument is required; on an optional one it silently becomes EMPTY and nobody is told
DENIED denied(reason...) always fatal, optional or not - a value that converted and was refused by a rule
INTERNAL_ERROR ParseResult.internalError(cause) stack to the owning plugin's log, generic line to whoever typed it
MISSING (framework only) a required argument nobody typed - no parser is ever consulted

unrecognized and denied are also available as protected shortcuts on ArgParser itself, which is what the example below uses. Each takes either the messages directly or a Supplier<List<ILocaleMessageBase>> for a reason that is expensive to build - the supplier only runs if somebody is actually going to read it.

public class ArgParserTemplateKit extends ArgParser<TemplateKit> {

    @FCLocale(lang = LocaleType.EN_US, text = "&c There is no kit named &e${name}&c.")
    @FCLocale(lang = LocaleType.PT_BR, text = "&c Não existe nenhum kit chamado &e${name}&c.")
    public static LocaleMessage KIT_NOT_FOUND;

    @FCLocale(lang = LocaleType.EN_US, text = "&c The kit &e${name}&c is not available in this world.")
    @FCLocale(lang = LocaleType.PT_BR, text = "&c O kit &e${name}&c não está disponível neste mundo.")
    public static LocaleMessage KIT_NOT_HERE;

    public ArgParserTemplateKit(ArgInfo argInfo) {
        super(argInfo);
    }

    @Override
    public ParseResult<TemplateKit> parse(ParseCall call) {
        TemplateKit kit = TemplateKit.getByName(call.argumento().toString());

        if (kit == null){
            return unrecognized(KIT_NOT_FOUND.addPlaceholder("name", call.argumento()));
        }

        if (!kit.isAvailableTo(call.sender())){
            //found it and refusing anyway: a rule, so it is fatal even on an [optional] argument
            return denied(KIT_NOT_HERE.addPlaceholder("name", kit.getName()));
        }

        return ParseResult.of(kit);
    }

    @Override
    public List<String> tabComplete(TabContext tabContext) {
        String lastWord = tabContext.getLastWord().toLowerCase();
        return TemplateKit.getAll().stream()
                .map(TemplateKit::getName)
                .filter(name -> name.toLowerCase().startsWith(lastWord))
                .sorted(String.CASE_INSENSITIVE_ORDER)
                .collect(Collectors.toList());
    }
}

📌 Never check isRequired() to decide whether to complain. Say which of the two refusals it is - unrecognized for "that is not one of mine", denied for "it is mine and I refuse it" - and the framework applies the cost. unrecognized() with no argument falls back to a generic "invalid argument".

MESSAGE and MESSAGE.addPlaceholder(...) are interchangeable as a reason, because addPlaceholder hands back a new message instead of mutating the shared static field - two senders at once never read each other's text.

Building one parser on another

map, retype and asDenied carry an outcome across types without rebuilding its reason, which is how a parser wraps another one:

@Override
public ParseResult<NumberWrapper> parse(ParseCall call) {
    return argParserNumber.parse(call).map(NumberWrapper::of);   // a failure retypes itself, untouched
}

retype() moves a failure to another T (it refuses a result that has a value), and asDenied() promotes an UNRECOGNIZED to DENIED for a token whose failure is fatal no matter how the argument was declared.

Aborting from deep inside a helper

Returning is the contract, but a helper three frames down can throw instead, carrying the same outcome:

throw new ArgParseException(denied(KIT_NOT_HERE.addPlaceholder("name", name)));

The engine adopts it exactly as it would a returned one - the shortcut buys no different policy. The exception has a single constructor (it always carries a failing ParseResult), no message of its own and no stack trace: it is control flow, not a bug report. Anything else a parser throws becomes INTERNAL_ERROR, so a broken parser stops one argument instead of the whole dispatch.

ParseCall is one object instead of three parameters, so giving parsers more context later is an addition rather than a signature break in every third-party parser in existence. It carries sender(), argumento() (EMPTY when the parser is answering without a token), argInfo(), path(), previouslyParsed(Class) - a value an earlier @Arg of the same method produced - and captured(nodePath, Class), which reads what an ancestor node captured, keyed by node path because a path may capture the same type twice.

The same ArgParser class resolves both a positional @Arg and a @FlagArg's value - there is no separate parser type for flags. ParseCall.isFlagValue() tells you which one you are in (also true while resolving a flag's def()), if your parser needs to behave differently for the two - see Flags.

For a contextual type, extend ArgParserContextual<T> instead: it takes an (ArgContextualInfo) constructor, a parse(ContextualParseCall) returning a ParseResult<T>, and requiresToBeAPlayer(). It answers with the same six kinds and has the same unrecognized/denied shortcuts, but nothing routes above it - there is no token to be optional about, so every failure it reports is fatal as it left the parser. ContextualParseCall carries sender() and context(), the surroundings of the invocation.

What a parser is told while tab-completing

tabComplete(TabContext) runs once per keystroke, and the traversal that leads to it counts tokens without resolving any. So TabContext hands over facts, not values:

Call What it gives
getLastWord() the word being completed
getIndex() where it sits in the raw args
getLocalIndex() where it sits once the command path is sliced off - 0 is the first token after the path, flags included
getPath() the segments already consumed, as typed
getCaptureToken(nodePath) what an ancestor node captured, raw: never parsed, never validated
@Override
public List<String> tabComplete(TabContext ctx) {
    String userToken = ctx.getCaptureToken("user");   // exactly as typed, maybe not a real player
    return homeCache.namesOf(userToken);
}

Resolving that token is your business, with your own cache. The framework does not pay, on every keystroke, for a value the traversal never needed - and when it cannot answer a position, the answer is an empty list, never a list of online player names dressed up as a real suggestion.


Registering a custom parser

Three ways, from most local to most global:

// 1. Per parameter - only this argument uses it.
public void kit(FCommandSender s, @Arg(name = "<kit>", parser = ArgParserTemplateKit.class) TemplateKit kit) { }

// 2. Per plugin - every TemplateKit parameter in THIS plugin's commands resolves through it.
ArgParserManager.addPluginParser(ecPluginData, TemplateKit.class, ArgParserTemplateKit.class);

// 3. Global - every ECPlugin can resolve this type.
ArgParserManager.addGlobalParser(TemplateKit.class, ArgParserTemplateKit.class);

Register the parser before the command that uses it, so the framework already knows how to resolve the type at registration time:

public static void registerCommands(ECPluginData ecPluginData) {
    ArgParserManager.addPluginParser(ecPluginData, TemplateKit.class, ArgParserTemplateKit.class);
    FinalCMDManager.registerCommand(ecPluginData, TemplateCommand.class);
}

Lookup order is plugin parsers first, then global, matching by exact class or assignability (isAssignableFrom). Contextual parsers use addPluginContextualParser / addGlobalContextualParser.


Platform parsers are registered lazily

The platform-specific parsers (Bukkit Player/World/ItemStack, Hytale types) are registered the first time the command framework initializes, through IPlatform.registerArgParsers(). On Bukkit that runs MinecraftArgParsers.initialize() exactly once (it's guarded to be idempotent), which is what adds Player, World, and the conditional OreDictEntry/FCWorldGuardRegion parsers. You don't call this yourself; it's part of the Platform Abstraction wiring.


See also

Clone this wiki locally