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 toplugins/in the directory that has therevbankexecutable).This is typically used for the "core" plugins that ship with RevBank.
-
begin with
~/for paths relative to the
HOMEdirectory. -
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'srunMUST 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
commandmethod may return a declaration too, but becausecommandmay 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_redois called, and the rewrite is included inhook_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_redois 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
undefto 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,
runis 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, orEnum($values)where$valuesis 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, orundefif 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 thatruncan 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
repeatcan'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.thencan't be combined withrepeat. -
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
repeatentry's 2nd and subsequent inputs. Seerejection_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 likerejection'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 itsrun.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 thatREDOre-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_MATCHit 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$retryis 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
deferare discarded, and$messageis 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
^Ccancels the command at a sub-prompt, but not the transaction because the cart is not empty.$reasonis 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_inputis 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.
$moreis 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 nowundef. 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
deferqueue. 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_checkoutdon'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").
$moreis 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.
$rewrittenis the word after any rewrites, and$allwordsis a reference to an array of the word and each of itsREDOrewrites. -
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.
$changesis a reference to an array of[old, new]pairs. For new products,oldwill be undef. For deleted products,newwill 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