RevBank::Plugins

Plugin mechanism for RevBank


Description

RevBank itself consists of a simple command line interface and a really brain dead shopping cart. All transactions, even deposits and withdrawals, are handled by plugins.

Plugins are defined in the plugins file in the REVBANK_DATADIR. Each plugin is a Perl source file.

In the plugins file, paths can either:

  • not contain /

    for files in REVBANK_PLUGINDIR (which defaults to plugins/ in the directory that has the revbank executable).

    This is typically used for the "core" plugins that ship with RevBank.

  • begin with ~/

    for paths relative to the HOME directory.

  • begin with /

    for absolute paths.

  • contain but not begin with /

    for paths relative to REVBANK_DATADIR (which defaults to ~/.revbank).

Plugins are always iterated over in the order they were defined in. The filename, regardless of which directory it's in, has to be unique.

The Perl namespace for each plugin is RevBank::Plugin::x, where x is the filename without the directory name.

Methods

RevBank::Plugins::load

Reads the plugins file and loads the plugins.

RevBank::Plugins->new

Returns a list of fresh plugin instances.

RevBank::Plugins::register($package)

Registers a plugin.

RevBank::Plugins::call_hooks($hook, @arguments)

Calls the given hook in each of the plugins. Non-standard hooks, called only by plugins, SHOULD be prefixed with the name of the plugin, and an underscore. For example, a plugin called cow can call a hook called cow_moo (which calls the hook_cow_moo methods).

There is no protection against infinite loops. Be careful!

Writing plugins

*** CAUTION ***
It is the responsibility of the PLUGINS to verify and normalize all
input. Behaviour for bad input is UNDEFINED. Weird things could
happen. Always use parse_user() and parse_amount() and test the
outcome for defined()ness. Use the result of the parse_*() functions
because that's canonicalised.

Don't do this:
    $entry->add_contra($u, $a, "Bad example");

But do this:
    $u = parse_user($u)   or return REJECT, "$u: No such user.";
    $a = parse_amount($a) or return REJECT, "$a: Invalid amount.";
    $entry->add_contra($u, $a, 'Good, except that $a is special in Perl :)');

There are two kinds of plugin methods: command methods and hooks. A plugin may define any number of command__keyword_ methods, optionally either a command_MATCH method or a command method, and any number of hooks.

The bare command is deprecated and support for it will be removed after 2028-09.

Command methods

Whenever a command is given in the 'outer' loop of revbank, the command methods of the plugins are called in order. Per plugin, command__keyword_ is tried first, then command_MATCH, and command as a fallback if neither exists.

command_MATCH is a catch-all and is expected to do some kind of string matching on the given input, to determine if it's valid or not. Like a keyword method, and unlike command, it must be free of side effects. See "Purity". (The literal command keyword MATCH is not available because it is special-cased for this feature.)

The deprecated command method sees every word too, but may do anything, and is therefore never called speculatively. It is still supported for a while, and third-party plugins using it keep working, but no plugin shipped with revbank uses it any more and a new one has no reason to: anything it can do, a keyword method or command_MATCH can do, and a side effect on every command belongs in hook_command.

If a command method declines the given command, it returns NEXT. This is generally only useful in command_MATCH and command.

A command method receives three arguments: the plugin object, the shopping cart, and the given input string. The plugin object (please call it $self) is temporary but persists as long as your plugin keeps control.

A command method MUST return with one of the following statements:

  • return NEXT;

    The plugin declines handling of the given command, and revbank should proceed with the next one.

    Only command methods may return NEXT. A declaration's run MUST NOT.

  • return REDO, "other input";

    The plugin rewrites the input, and revbank starts over with the new word, from the first plugin. Use it for a command that translates one form of input into another. See "Rewriting input".

  • return REJECT, "Reason";

    The plugin decides that the input should be rejected for the given reason. RevBank will either query the user again, or (if there is any remaining input in the buffer) abandon the command and offer the input again for editing.

  • return ABORT, "Reason";

  • return ABORT;

    The plugin decides that the transaction should be aborted.

  • return ACCEPT;

    The plugin has finished processing the command. No other plugins will be called.

  • return (declaration, see Declarative commands);

    The plugin handles the command, and gathers its arguments as declared.

    A command method may return a declaration too, but because command may have side effects, it's not called speculatively for tab completion information, so there won't be any tab completion or hints on the main prompt.

The literal input string abort is a hard coded special case, and will never reach the plugin's command methods.

Rewriting input

A command method can rewrite the word in two ways.

  • return REDO, "other input";

    Every plugin sees the new word, including the ones before this one, because RevBank starts over from the first plugin. hook_redo is called, and the rewrite is included in hook_invalid_input's $allwords. A rewrite loop is cut off.

  • $_[2] = "other input"; return NEXT;

    The older way: the input string argument is an alias, so assigning to it changes the word in place. Only the plugins after this one see the new word, and hook_redo is not called. It needs a sub without a signature, because signature parameters are copies.

No plugin that ships with RevBank still rewrites words in a command method with $_[2]. hook_prompt, hook_input and hook_command use the same aliasing to let a plugin alter their arguments.

Purity

See https://en.wikipedia.org/wiki/Pure_function for the formal definition.

A command__keyword_ or command_MATCH method MUST NOT have side effects: it should only say what would happen. That allows the main prompt to be context-aware for tab completion and hints.

RevBank also calls these methods for words that have been typed but not submitted, possibly on every keystroke, on a plugin object of their own. Everything in a returned declaration except run is walked the same way. The parts that only run for real input are run and the guard; anything that prints, writes a file, or touches the cart belongs in one of those.

A plugin method may keep state on $self across an invocation, including across a REDO, but it should give the same answer when called on a fresh object. The object for the eventual real execution (which runs run) is not the same as for the speculative invocation which is used for prompt context.

Don't use the $cart state to determine whether the command is known, because adduser also wants to know if a requested username is actually available. Conditions on the cart's contents belong in guard.

Declarative commands

A command that is triggered by a fixed keyword should be written as command__keyword_ and one that matches a pattern (like a regex) as command_MATCH. Such a method gets the same three arguments and returns a declaration of the command's parameters:

sub command_give($self, $cart, $command, @) {
    params => [
        { name => 'beneficiary', type => User,
          prompt => "User to give to" },
        { name => 'amount', type => Amount,
          prompt => sub ($args) { "Amount to give to $args->{beneficiary}" } },
        { name => 'reason', type => Description,
          prompt => "Short description ('x' for no message)" },
    ],
    run => sub ($args) {
        ...
        return ACCEPT;
    },
}

The prompting, parsing and rejecting are then handled automatically, and $args holds the gathered arguments by name. Callbacks are closures, so $self and $cart are simply in scope; there is no context object to pass around.

The main prompt can see what a parameter accepts without running the command. That is what makes the prompt context aware for tab completion and hints, and why everything in a declaration except run and guard must be free of side effects. See "Purity".

An alias can be made with a glob assignment:

*command_steal = \&command_take;

Declaration keys

  • guard

    Called without arguments before the first sub-prompt. Returns a reason to reject the command, or undef to allow it. Use it for preconditions such as "Undo is not available mid-transaction.", so that the user is told before being asked for arguments rather than after.

    Unlike the rest of a declaration, a guard may have side effects: it is skipped during completion, and its answer is not needed there.

  • params

    An arrayref of parameter specifications (see below), in the order they are asked for. Optional: without it, run is called right away.

  • run

    Required. Called with the gathered arguments once they are all present, and returns like any command method (usually ACCEPT).

Parameter keys

  • name

    The key this parameter is stored under.

  • type

    One of User, Amount, Description, Text, or Enum($values) where $values is a hashref or a coderef returning one. A type supplies the parser, the rejection message and the tab completion candidates. See RevBank::Type.

  • parse

    A coderef sub ($input, $args) that returns the parsed value of the input, or undef if it is invalid. Needed when there's no applicable type. With a type, it overrides the type's parser, and the type still supplies the rest. Empty input is rejected without calling it.

  • complete

    A coderef sub ($args, $word) that returns the tab completion candidates for the word being typed. Overrides the type's.

  • prompt

    A string, or a coderef receiving the arguments gathered so far.

  • info

    Explanation shown on the lines above the prompt when the parameter is asked for interactively. A string, or a coderef like prompt's.

    The info is not shown when the user passes the argument on the main command line. Where the user has to be told either way, $self->info_shown($name) says whether it was, so that run can say the same thing itself:

    say $adjusted if not $self->info_shown('reason');
    
  • repeat

    When true, the parameter collects a list, and its value will be an arrayref. The list ends as soon as a word parses as the following parameter, which is therefore tried first. Lists have one or more items.

    Because the following parameter specifies the list terminator, a parameter with repeat can't be the last.

  • then

    A coderef sub ($value, $args) returning further parameter specifications, for a command whose remaining parameters depend on the value just given.

    then can't be combined with repeat.

  • rejection

    Overrides the type's rejection message, for the rare parameter that needs different wording or that doesn't have a standard type. Either a string, or a coderef sub ($input, $args) when the message depends on what was typed.

    It is not used when the word could also have been taken by the next parameter, like for a repeat entry's 2nd and subsequent inputs. See rejection_clause.

  • rejection_clause

    Overrides the type's rejection message for when the word could have been taken by more than one parameter. The message is then composed of each parameter's clause, like No such user, and not a valid amount, so the clause is a fragment rather than a sentence. A string, or a coderef like rejection's.

  • reject

    Deprecated name for rejection.

  • eager

    When true, causes the word to be judged while it's being typed, so that the hint above the prompt turns into a rejection at the character that makes the input impossible. Defaults to the type's, or to false when the parameter has its own parse.

  • takeover

    An arrayref of [ $class, $coderef ] pairs, normally contributed by other plugins through a hook, which are offered this parameter's input before its own type is. A plugin that claims the word takes over: the declaration it returns replaces the rest of this one, including its run.

    The coderef is called as a method on an object of the named plugin class, which RevBank constructs itself, with the same arguments as command_MATCH, and should return one of the same things. The difference is that REDO re-offers the rewritten input to the same parameter, rather than restarting the line.

    A takeover's sub is also called for a word that is still being typed, so like command_MATCH it should not have side effects.

Cart state

$cart->attribute($key), $cart->attribute($key => $value)

Per-cart storage for plugins that need to remember something for the duration of the current transaction. Plugins can access each other's attributes: in a way, they act like global variables. Prefix the key with the plugin's name to avoid collisions.

The two argument form sets the attribute and returns the new value.

When the transaction ends, the whole cart is discarded, taking the attributes with it. When a checkout is aborted recoverably (see "Exceptions"), the attributes are restored together with the rest of the cart.

$cart->has_attribute($key)

Returns whether the attribute exists and is defined.

$entry->description, $entry->description($new, $for_the_record = 0)

The description of a cart entry, as shown to the user and written to the logs. Setting it marks the entry as changed, so the cart is redisplayed, unless $for_the_record is true.

Exceptions

  • die RevBank::Exception::RejectInput->new($reason, $retry);

    Like returning REJECT, but can be thrown from anywhere, which is useful when the rejection happens too deep to return from. When $retry is true, the command is abandoned and the input is offered again for editing.

  • die RevBank::Exception::AbortCheckoutRecoverably->new($message);

    Only valid in hook_checkout_prepare. Use when the checkout failed for a reason that is recoverable (like a failed card payment).

    No accounts are updated. The cart is restored to the state it had before the prepare hooks ran, callbacks that were queued with defer are discarded, and $message is given to the user as a rejection to retry.

Hooks

Hooks are called at specific points in the processing flow, and MAY introspect the shopping cart. They SHOULD NOT manipulate the shopping cart, but this option is provided anyway, to allow for interesting hacks. If you do manipulate the cart, re-evaluate your assumptions when upgrading!

Hooks SHOULD NOT prompt for input or execute programs that do so.

Hooks are called as class methods. The return value MUST be either ABORT, which causes the ongoing transaction to be aborted, or a non-reference, which will be ignored.

startup, plugins_loaded and register run before the shell. ABORT from one of those prints the reason and makes revbank exit with an error.

Hooks SHOULD have a dummy @ parameter at the end of their signatures, so they don't break when more information is added.

The following hooks are available, with their respective arguments:

  • hook_register($class, $plugin, @)

    Called when a new plugin is registered.

  • hook_plugins_loaded($class, @)

    Called when all plugins have been loaded and registered.

  • hook_startup($class, @)

    Called once, after the plugins are loaded and before the shell is entered.

  • hook_shell($class, @)

    Called once, when the interactive shell is entered. Not called with --command.

  • hook_abort($class, $cart, @)

    Called when a transaction is being aborted, right before the shopping cart is emptied.

  • hook_interrupt($class, $cart, $reason, @)

    Called when ^C cancels the command at a sub-prompt, but not the transaction because the cart is not empty. $reason is an array reference.

  • hook_prompt($class, $cart, $prompt, @)

    Called just before the user is prompted for input interactively. The prompt MAY be altered by the plugin.

  • hook_input($class, $cart, $input, $split_input, @)

    Called when user input was given. $split_input is a boolean that is true if the input will be split on whitespace, rather than treated as a whole. The input MAY be altered by the plugin.

  • hook_ctrlat($class, @)

    Called when Ctrl+@ is pressed at a prompt. RevBank itself does nothing with that key.

  • hook_command($class, $cart, $word, @)

    Called for the first word (or the first word after another command's arguments) given at the main prompt, just before the command methods are, and not again for a word that a plugin rewrote with REDO. The word MAY be altered by the plugin.

  • hook_redo($class, $plugin, $old, $new, @)

    Called when a plugin rewrites a word with REDO. Not called for tab completion.

  • hook_accept($class, $cart, $plugin, $more, @)

    Called when a command is complete. $more is true when more input follows on the same line.

  • hook_add_entry($class, $cart, $entry, @)

    Called when an entry is added to the cart, before it is added.

    Be careful to avoid infinite loops if you add new stuff.

  • hook_added_entry($class, $cart, $entry, @)

    Called after an entry was added to the cart.

  • hook_cart_changed($class, $cart, @)

    Called before a prompt when the cart has changed, so that it can be displayed.

  • hook_checkout_prepare($class, $cart, $account, @)

    Called when the transaction is about to be processed. In this phase, the cart and its entries can still be manipulated. If the hook throws an exception, the transaction is aborted.

    Before v13.0.0, the transaction ID was known at this point and the hook ran while the global lock was held. The 4th argument was $transaction_id, and is now undef. It will probably be removed or reused in a future version.

    This hook MAY be called more than once for the same cart, because a checkout can be aborted recoverably (see "Exceptions"). The cart is restored between the attempts, but nothing else is.

    A plugin that stores information (e.g. by writing to file) about a successful transaction should thus wait until hook_checkout, or more conveniently, use $cart->defer:

    $cart->defer(sub ($transaction_id) { spurt $filename, $data });
    

    Exceptions thrown from deferred code abort the whole transaction. That's an emergency brake to prevent some inconsistent state, but such exceptions can still cause inconsistent external state (e.g. card payment happened but user never got their product or deposit), incomplete info in the logs, and a hole in the transaction ID sequence.

    Deferred code MUST NOT change the cart or entries anymore.

    Every checkout attempt starts with an empty defer queue. Deferring outside of a checkout is not allowed.

  • hook_checkout($class, $cart, $account, $transaction_id, @)

    Called when the transaction is finalized, before accounts are updated. The cart and cart entries must not be changed.

    Deferred code (see above) runs immediately after this hook, before accounts are updated. It differs from the hook: exceptions thrown from hook_checkout don't stop the checkout.

    Either way, anything that runs at this point MUST NOT update anything about the transaction anymore.

  • hook_checkout_done($class, $cart, $account, $transaction_id, @)

    Called when the transaction is finalized, after accounts were updated.

  • hook_reject($class, $plugin, $reason, $abort, @)

    Called when input is rejected by a plugin.

  • hook_retry($class, $plugin, $reason, $more, @)

    Called when input is rejected by a plugin and the command is abandoned, to be offered again for editing (see "Exceptions"). $more is true when more input followed the rejected word.

  • hook_invalid_input($class, $cart, $word, $rewritten, $allwords, @)

    Called when input was not recognised by any of the plugins. $rewritten is the word after any rewrites, and $allwords is a reference to an array of the word and each of its REDO rewrites.

  • hook_plugin_fail($class, $plugin, $error, @)

    Called when a plugin fails.

  • hook_account_created($class, $account, @)

    Called when a new account was created.

  • hook_account_deleted($class, $account, @)

    Called when an account was deleted.

  • hook_account_balance($class, $account, $old, $delta, $new, $transaction_id, @)

    Called when an account is updated.

  • hook_products_changed($class, $changes, $mtime, @)

    Called after reading a changed products file. $changes is a reference to an array of [old, new] pairs. For new products, old will be undef. For deleted products, new will be undef.

    The mtime is the mtime of the products file, not necessarily when the product was changed.

    Caveats: Only things that change during runtime cause this hook to be called. When multiple revbank instances are running, each process gets this hook. When the products file is modified externally, the new file is loaded only after user interaction. When a product's primary id changes, it is registered as a deletion and addition, not a change.

Default messages can be silenced by overriding the hooks in RevBank::Messages. Such a hack might look like:

undef &RevBank::Messages::hook_abort;

sub hook_abort($class, $cart, @) {
    print "This message is much better!\n"
}

Utility functions

Several global utility functions are available. See RevBank::Global.

Prompt and method (deprecated)

Before declarative commands, a command method gathered an argument by returning a prompt and a continuation method to call with it:

return "Prompt", $method;

The argument is taken from the input buffer if extra input was given, or else, requested interactively. $method is a code reference, called like a command method with the argument as the input string, and returns like one, except that it MUST NOT return NEXT.

The plugin object can be used as a scratchpad for carrying over values from one method call to the next.

$method may have side effects, so it is never called speculatively: tab completion and hints on the main prompt show only its prompt.

A ("run", $coderef) return is a declaration consisting only of a run, so a prompt of literally run is not possible.

Author

Juerd Waalboer #####@juerd.nl