The Perl and Raku Conference 2025: Greenville, South Carolina - June 27-29 Learn more

#!/usr/bin/env perl
my $version = $App::optex::VERSION;
=encoding utf8
=head1 NAME
optex - General purpose command option wrapper
=head1 VERSION
Version 1.02
=head1 SYNOPSIS
B<optex> I<command> [ B<-M>I<module> ] ...
or I<command> -> B<optex> symlink, or
B<optex> I<options> [ -l | -m ] ...
--link, --ln create symlink
--unlink, --rm remove symlink
--ls list link files
--rc list rc files
--nop, -x disable option processing
--[no]module disable module option on arguments
=cut
use v5.14;
use utf8;
use Encode;
use open IO => ':utf8';
$Data::Dumper::Sortkeys = 1;
use Cwd qw(abs_path);
use List::Util qw(uniq max);
use Text::ParseWords qw(shellwords);
use TOML;
binmode STDOUT, ":encoding(utf8)";
binmode STDERR, ":encoding(utf8)";
our $rcloader;
our $debug //= $ENV{DEBUG_OPTEX};
our $no_operation;
our $mod_opt = '-M';
our $mod_arg = 1; # Process -M option in target command
our $exit_code;
my($cmd_dir, $cmd_name) = ($0 =~ m{ (.*) / ([^/]+) $ }x) or die;
my($abs_dir, $abs_name) = (abs_path($0) =~ m{ (.*) / ([^/]+) $ }x) or die;
my $env_MODULE_PATH = sprintf '%s_MODULE_PATH', uc($abs_name);
my $env_ROOT = sprintf '%s_ROOT', uc($abs_name);
my $env_BINDIR = sprintf '%s_BINDIR', uc($abs_name);
my $env_CONFIG = sprintf '%s_CONFIG', uc($abs_name);
my $HOME = $ENV{HOME} or die "No \$HOME.\n";
my $config_dir = $ENV{$env_ROOT} || "${HOME}/.${abs_name}.d";
my $module_dir = $config_dir;
my $bin_dir = $ENV{$env_BINDIR} || "$config_dir/bin";
my $config_file = $ENV{$env_CONFIG} || "$config_dir/config.toml";
##
## decode @ARGV
##
@ARGV = map { utf8::is_utf8($_) ? $_ : decode('utf8', $_) } @ARGV;
##
## load config file
##
my $config = sub {
my $file = shift;
my $fh = IO::File->new($file, "<:encoding(UTF-8)") or return {};
my $toml = do { local $/; <$fh> };
my($data, $err) = from_toml $toml;
die "$file: $err\n" unless $data;
$data;
}->($config_file);
my $alias = $config->{alias} //= {};
my $nomodule = $config->{"no-module"} //= [];
my %nomodule = do {
if (ref $nomodule eq 'ARRAY') {
map { $_ => 1 } @{$nomodule};
}
elsif (ref $nomodule eq 'HASH') {
%{$nomodule};
}
else {
($nomodule => 1);
}
};
##
## setup Getopt::EX
##
$rcloader = Getopt::EX::Loader->new(
BASECLASS => [ '', 'App::optex', 'Getopt::EX' ],
IGNORE_NO_MODULE => 1,
);
load_rc("$config_dir/default.rc");
##
## setup module search path
##
my @private_mod_path = (
do {
if (my $mod_path = $ENV{$env_MODULE_PATH}) {
split /:/, $mod_path;
} else {
();
}
},
$module_dir,
);
prepend_path(@private_mod_path);
##
## get target command name
##
my $target_name = do {
if ($cmd_name ne $abs_name) {
$cmd_name;
} else {
self_option(\@ARGV);
if (@ARGV) {
shift @ARGV;
} else {
usage();
exit 1;
}
}
};
##
## alias
##
my $aliased_name;
if (my $alias = $alias->{$target_name}) {
my($name, @opts) = do {
if (ref $alias eq 'ARRAY') {
@{$alias};
} else {
shellwords $alias;
}
};
if ($name ne '') {
$aliased_name = $name;
unshift @ARGV, @opts;
}
}
if ($nomodule{$target_name}) {
$mod_arg = 0;
}
##
## prepare command specific module path
##
my @command_mod_path =
grep { -d $_ } map { "$_/$target_name" } @private_mod_path;
prepend_path(@command_mod_path, @private_mod_path);
if ($mod_arg and @ARGV > 0 and $ARGV[0] eq $mod_opt) {
show_modules();
exit;
}
##
## load command specific rc file
##
unless ($no_operation) {
load_rc("$config_dir/$target_name.rc");
$rcloader->configure(PARSE_MODULE_OPT => $mod_arg);
$rcloader->deal_with(\@ARGV);
}
my $exec_name = $aliased_name || search_path($target_name);
warn "$abs_name: exec $exec_name @ARGV\n" if $debug;
##
## execute target command
##
my $status = system $exec_name, @ARGV;
END {
if (defined $exit_code) {
$? = $exit_code;
} elsif (defined $status) {
$? = $status >> 8;
}
close STDOUT;
close STDERR;
}
######################################################################
sub load_rc {
my $rc = shift;
$rcloader->load(FILE => $rc);
warn "$abs_name: load $rc\n" if $debug;
}
sub search_path {
my $new = shift;
for my $path (split /:+/, $ENV{PATH}) {
-x "$path/$new" or next;
next if $path eq $cmd_dir and $new eq $cmd_name;
return "$path/$new";
}
return $new;
}
sub self_option {
my $argv = shift;
local $rcloader = Getopt::EX::Loader->new(
BASECLASS => [ '', 'App::optex', 'Getopt::EX' ],
);
$rcloader->load(FILE => "$config_dir/$abs_name.rc");
$rcloader->deal_with($argv);
use Getopt::Long qw(GetOptionsFromArray);
Getopt::Long::Configure(qw"bundling require_order");
use Getopt::EX::Hashed qw(has); {
Getopt::EX::Hashed->configure(DEFAULT => [ is => 'ro' ]);
has debug => " ! d " , action => sub { $debug = $_[1] };
has version => " v " ;
has man => " h " ;
has link => " ln " ;
has unlink => " rm " ;
has force => " f " ;
has ls => " " ;
has rc => " " ;
has long => " l " ;
has path => " p " ;
has cat => " m " ;
has M => " " ;
has module => " ! " , action => sub { $mod_arg = $_[1] };
has nop => " ! x " , action => sub { $no_operation = $_[1] };
has exit => " =i " , action => sub { $exit_code = $_[1] } ;
}; no Getopt::EX::Hashed;
my $opt = Getopt::EX::Hashed->new->reset();
GetOptionsFromArray($argv, $opt->optspec) or usage();
if ($opt->man) {
exec "perldoc $abs_name";
die "exec: $!";
}
elsif ($opt->version) {
print $version, "\n";
exit;
}
elsif ($opt->link || $opt->unlink) {
_symlink($argv, $opt);
exit 0;
}
elsif ($opt->ls) {
_ls($argv, $opt);
exit 0;
}
elsif ($opt->rc) {
_rc($argv, $opt);
exit 0;
}
elsif ($opt->M) {
show_modules();
exit 0;
}
return;
}
sub _symlink {
my($argv, $op) = @_;
-d $bin_dir or die "Directory $bin_dir does not exist.\n";
my @target = @$argv;
for my $target (@target) {
my $link = "$bin_dir/$target";
if ($op->{link}) {
-f $link and do { warn "$link already exists.\n"; next };
symlink $0, $link or die "$link: $!\n";
print "$link created.\n";
}
elsif ($op->{unlink}) {
-l $link or do { warn "$link is not symlink\n"; next };
if ((my $name = readlink $link) ne $0 ) {
if (not $op->{force}) {
warn
"$link has unexpected link: -> $name\n" .
"Use -f option to force unlink.\n" ;
next;
}
}
unlink $link or die "$link: $!\n";
print "$link removed.\n";
}
}
}
use Text::VisualWidth::PP 'vwidth';
sub _ls {
my($argv, $op) = @_;
use IO::Dir;
my $dir = IO::Dir->new($bin_dir) or die "$bin_dir: $!\n";
my @dirent = do {
sort { $a cmp $b }
map { decode 'utf8', $_ }
grep { not /^\./ }
$dir->read;
};
$dir->close;
my @aliases = sort keys %{$alias};
my $max = max map { vwidth $_ } @dirent, @aliases;
print "[link]\n";
for my $ent (@dirent) {
my $path = "$bin_dir/$ent";
if (-l $path) {
print "\t";
print $op->{path} ? $path : $ent;
if ($op->{long}) {
print ' ' x ($max - vwidth $ent);
my $target = do {
# if (my $val = $alias->{$ent}) {
# ref($val) eq 'ARRAY' ? join(' ', @$val) : $val;
# } else {
readlink $path;
# }
};
print " -> ", $target;
}
print "\n";
}
warn "$ent: not exist\n" unless -f $path;
}
print "[alias]\n";
for my $key (@aliases) {
my $val = $alias->{$key};
my $command = ref($val) eq 'ARRAY' ? join(' ', @$val) : $val;
print "\t";
print $key;
if ($op->{long}) {
print ' ' x ($max - vwidth $key);
printf " => %s", $command;
}
print "\n";
}
}
sub _rc {
my($argv, $op) = @_;
my @command = @$argv;
my @rc = map { s/(?<!\.rc)$/.rc/r } @command;
my %rc = map { ($_ => 1) } @rc;
use IO::Dir;
my $dir = IO::Dir->new($config_dir) or die "$config_dir: $!\n";
my @dirent = do {
grep { %rc == 0 or $rc{$_} }
grep { /\.rc$/ }
@rc ? @rc : sort $dir->read
};
$dir->close;
for my $ent (@dirent) {
my $path = "$config_dir/$ent";
if ($op->{cat}) {
use IO::File;
my $fh = IO::File->new($path) or do {
warn "$path: $!\n";
next;
};
while (<$fh>) {
print "$ent:" if @dirent > 1;
print;
}
$fh->close;
} else {
print $op->{long} ? $path : $ent;
print "\n";
}
}
}
######################################################################
sub usage {
pod2usage(-verbose => 0,
-message => <<" EOS" =~ s/^\s+//mgr
Use `perldoc $abs_name` for document.
Use `$abs_name [command] ${mod_opt}help` for available options.
EOS
);
}
my @ORIG_INC; BEGIN { @ORIG_INC = @INC }
my @mod_path;
sub prepend_path {
@mod_path = uniq @_;
@INC = (@mod_path, @ORIG_INC);
}
sub show_modules {
my $path = @_ ? shift : [
@mod_path,
grep { -d } map { "$_/App/optex" } @INC,
];
print "MODULES:\n";
for my $path (@$path) {
my($name) = $path =~ m:([^/]+)$:;
my @module = do {
# grep { not /\bdefault\.pm$/ }
glob "$path/[a-z0-9]*.pm";
};
next unless @module;
print " $path\n";
for my $mod (@module) {
printf " ${mod_opt}%s\n", $mod =~ /([^\/]*)\.pm/;
}
print "\n";
}
}
=head1 DESCRIPTION
B<optex> is a general purpose command option handling wrapper
utilizing Perl module L<Getopt::EX>. It enables user to define their
own option aliases for any commands on the system, and provide module
style extensibility.
Target command is given as an argument:
% optex command
or as a symbolic linked file to B<optex>:
command -> optex
If the configuration file F<~/.optex.d/>I<command>F<.rc> exists, it is
evaluated before execution and command arguments are pre-processed
using it.
=head2 OPTION ALIASES
Think of macOS's C<date> command, which does not have C<-I[TIMESPEC]>
option. Using B<optex>, these can be implemented by preparing
following setting in F<~/.optex.d/date.rc> file.
option -I -Idate
option -Idate +%F
option -Iseconds +%FT%T%z
option -Iminutes +%FT%H:%M%z
option -Ihours +%FT%H%z
option --iso-8601 -I
option --iso-8601=date -Idate
option --iso-8601=seconds -Iseconds
option --iso-8601=minutes -Iminutes
option --iso-8601=hours -Ihours
Then next command will work as expected.
% optex date -Iseconds
If a symbolic link C<< date -> optex >> is found in command search
path, you can use it just same as standard command, but with
unsupported options.
% date -Iseconds
Common configuration is stored in F<~/.optex.d/default.rc> file, and
those rules are applied to all commands executed through B<optex>.
Actually, C<--iso-8601> option can be defined simpler as this:
option --iso-8601 -I$<shift>
This works fine almost always, but fails with sole C<--iso-8601>
option preceding other option like this:
% date --iso-8601 -u
=head2 COMMAND ALIASES
B<optex>'s command alias is no different from the alias function of
shell, but it is effective in that it can be executed as a command
from a tool or script, and can be managed collectively in a
configuration file.
Command aliases can be set in the configuration file
(F<~/.optex.d/config.toml>) like this:
[alias]
tc = "optex -Mtextconv"
You can make symbolic link from C<tc> to C<optex> like this:
% optex --ln tc
And include F<$HOME/.optex.d/bin> in your C<PATH> evnironment.
The C<textconv> module can be used to convert files given as arguments
to plain text. Defined in this way, Word files can be compared as
follows.
% tc diff A.docx B.docx
Alias name is used to find rc file and module directory. In the above
example, F<~/.optex.d/tc.rc> and F<~/.optex.d/tc/> will be referred.
It is also possible to write shell scripts in the config file. The
following example implements the C-shell C<repeat> command.
[alias]
repeat = [ 'bash', '-c', '''
while getopts 'c:i:' OPT; do
case $OPT in
c) count=$OPTARG;;
i) sleep=$OPTARG;;
esac
done; shift $((OPTIND - 1))
case $1 in
[0-9]*) count=$1; shift;;
esac
while ((count--)); do
eval "$*"
[ "$sleep" ] && (( count > 0 )) && sleep $sleep
done
''', 'repeat' ]
Read L<CONFIGURATION FILE> section.
=head2 MACROS
Complex string can be composed using macro C<define>. Next example is
an awk script to count vowels in the text, to be declared in file
F<~/.optex.d/awk.rc>.
define __delete__ /[bcdfgkmnpsrtvwyz]e( |$)/
define __match__ /ey|y[aeiou]*|[aeiou]+/
define __count_vowels__ <<EOS
{
s = tolower($0);
gsub(__delete__, " ", s);
for (count=0; match(s, __match__); count++) {
s=substr(s, RSTART + RLENGTH);
}
print count " " $0;
}
EOS
option --vowels __count_vowels__
This can be used like this:
% awk --vowels /usr/share/dict/words
When setting complex option, C<expand> directive is useful. C<expand>
works almost same as C<option>, but effective only within the file
scope, and not available for command line option.
expand repository ( -name .git -o -name .svn -o -name RCS )
expand no_dots ! -name .*
expand no_version ! -name *,v
expand no_backup ! -name *~
expand no_image ! -iname *.jpg ! -iname *.jpeg \
! -iname *.gif ! -iname *.png
expand no_archive ! -iname *.tar ! -iname *.tbz ! -iname *.tgz
expand no_pdf ! -iname *.pdf
option --clean \
repository -prune -o \
-type f \
no_dots \
no_version no_backup \
no_image \
no_archive \
no_pdf
% find . --clean -print
=head2 MODULES
B<optex> also supports module extension. In the example of C<date>,
module file is found at F<~/.optex.d/date/> directory. If default
module, F<~/.optex.d/date/default.pm> exists, it is loaded
automatically on every execution.
This is a normal Perl module, so package declaration and the final
true value is necessary. Between them, you can put any kind of Perl
code. For example, next program set environment variable C<LANG> to
C<C> before executing C<date> command.
package default;
$ENV{LANG} = 'C';
1;
% /bin/date
2017年 10月22日 日曜日 18時00分00秒 JST
% date
Sun Oct 22 18:00:00 JST 2017
Other modules are loaded using C<-M> option. Unlike other options,
C<-M> have to be placed at the beginning of argument list. Module
files in F<~/.optex.d/date/> directory are used only for C<date>
command. If the module is placed on F<~/.optex.d/> directory, it can
be used from all commands.
If you want use C<-Mes> module, make a file F<~/.optex.d/es.pm> with
following content.
package es;
$ENV{LANG} = 'es_ES';
1;
% date -Mes
domingo, 22 de octubre de 2017, 18:00:00 JST
When the specified module was not found in library path, B<optex>
ignores the option and stops argument processing immediately. Ignored
options are passed through to the target command.
Module is also used with subroutine call. Suppose
F<~/.optex.d/env.pm> module look like:
package env;
sub setenv {
while (($a, $b) = splice @_, 0, 2) {
$ENV{$a} = $b;
}
}
1;
Then it can be used in more generic fashion. In the next example,
first format is easy to read, but second one is more easy to type
because it does not have special characters to be escaped.
% date -Menv::setenv(LANG=de_DE) # need shell quote
% date -Menv::setenv=LANG=de_DE # alternative format
So 22 Okt 2017 18:00:00 JST
Option aliases can be also declared in the module, at the end of file,
following special literal C<__DATA__>. Using this, you can prepare
multiple set of options for different purposes. Think about generic
B<i18n> module:
package i18n;
1;
__DATA__
option --cn -Menv::setenv(LANG=zh_CN) // 中国語 - 簡体字
option --tw -Menv::setenv(LANG=zh_TW) // 中国語 - 繁体字
option --us -Menv::setenv(LANG=en_US) // 英語
option --fr -Menv::setenv(LANG=fr_FR) // フランス語
option --de -Menv::setenv(LANG=de_DE) // ドイツ語
option --it -Menv::setenv(LANG=it_IT) // イタリア語
option --jp -Menv::setenv(LANG=ja_JP) // 日本語
option --kr -Menv::setenv(LANG=ko_KR) // 韓国語
option --br -Menv::setenv(LANG=pt_BR) // ポルトガル語 - ブラジル
option --es -Menv::setenv(LANG=es_ES) // スペイン語
option --ru -Menv::setenv(LANG=ru_RU) // ロシア語
This can be used like:
% date -Mi18n --tw
2017年10月22日 週日 18時00分00秒 JST
You can declare autoload module in your F<~/.optex.d/optex.rc> like:
autoload -Mi18n --cn --tw --us --fr --de --it --jp --kr --br --es --ru
Then you can use them without module option. In this case, option
C<--ru> is replaced by C<-Mi18n --ru> automatically.
% date --ru
воскресенье, 22 октября 2017 г. 18:00:00 (JST)
Module C<i18n> is implemented as L<Getopt::EX::i18n> and included in
this distribution. So it can be used as above without additional
installation.
=head1 STANDARD MODULES
Standard modules are installed at C<App::optex>, and they can be
addressed with and without C<App::optex> prefix.
=over 4
=item -MB<help>
Print available option list. Option name is printed with substitution
form, or help message if defined. Use B<-x> option to omit help
message.
Option B<--man> or B<-h> will print document if available. Option
B<-l> will print module path. Option B<-m> will show the module
itself. When used after other modules, print information about the
last declared module. Next command show the document about B<second>
module.
% optex -Mfirst -Msecond -Mhelp --man
=item -MB<debug>
Print debug messages.
=item -MB<util::argv>
Module to manipulate command argument.
See L<App::optex::util::argv> for detail.
=item -MB<util::filter>
Module to implement command input/output filters.
See L<App::optex::util::filter> for detail.
=back
=head1 Getopt::EX MODULES
In addition to its own modules, B<optex> can also use C<Getopt::EX>
modules. The standard C<Getopt::EX> modules installed are these.
=over 4
=item -MB<i18n> (L<Getopt::EX::i18n>)
You can display a Greek calendar by doing the following:
optex -Mi18n cal --gr
=back
=head1 OPTIONS
These options are not effective when B<optex> was executed from
symbolic link.
=over 4
=item B<--link>, B<--ln> [ I<command> ]
Create symbolic link in F<~/.optex.d/bin> directory.
=item B<--unlink>, B<--rm> [ B<-f> ] [ I<command> ]
Remove symbolic link in F<~/.optex.d/bin> directory.
=item B<--ls> [ B<-l> ] [ I<command> ]
List symbolic link files in F<~/.optex.d/bin> directory.
=item B<--rc> [ B<-l> ] [ B<-m> ] [ I<command> ]
List rc files in F<~/.optex.d> directory.
=item B<--nop>, B<-x> I<command>
Stop option manipulation. Use full pathname otherwise.
=item B<-->[B<no>]B<module>
B<optex> deals with module option (-M) on target command by default.
However, there is a command which also uses same option for own
purpose. Option B<--nomodule> disables that behavior. Other option
interpretation is still effective, and there is no problem using
module option in rc or module files.
=item B<--exit> I<status>
Usually B<optex> exits with status of executed command. This option
override it and force to exit with specified status code.
=back
=head1 CONFIGURATION FILE
When starting up, B<optex> reads configuration file
F<~/.optex.d/config.toml> which is supposed to be written in TOML
format.
=head2 PARAMETERS
=over 4
=item B<no-module>
Set commands for which B<optex> does not interpret module option
B<-M>. If the target command is found in this list, it is executed as
if option B<--no-module> is given to B<optex>.
no-module = [
"greple",
"pgrep",
]
=item B<alias>
Set command aliases. Example:
[alias]
pgrep = [ "greple", "-Mperl", "--code" ]
hello = "echo -n 'hello world!'"
Command alias can be invoked either from symbolic link and command
argument.
=back
=head1 FILES AND DIRECTORIES
=over 4
=item F<PERLLIB/App/optex>
System module directory.
=item F<~/.optex.d/>
Personal root directory.
=item F<~/.optex.d/config.toml>
Configuration file.
=item F<~/.optex.d/default.rc>
Common startup file.
=item F<~/.optex.d/>I<command>F<.rc>
Startup file for I<command>.
=item F<~/.optex.d/>I<command>F</>
Module directory for I<command>.
=item F<~/.optex.d/>I<command>F</default.pm>
Default module for I<command>.
=item F<~/.optex.d/bin>
Default directory to store symbolic links.
This is not necessary, but it seems a good idea to make special
directory to contain symbolic links for B<optex>, placing it in your
command search path. Then you can easily add/remove it from the path,
or create/remove symbolic links.
=back
=head1 ENVIRONMENT
=over 4
=item OPTEX_ROOT
Override default root directory F<~/.optex.d>.
=item OPTEX_CONFIG
Override default configuration file F<OPTEX_ROOT/config.toml>.
=item OPTEX_MODULE_PATH
Set module paths separated by colon (C<:>). These are inserted before
standard path.
=item OPTEX_BINDIR
Override default symbolic link directory F<OPTEX_ROOT/bin>.
=back
=head1 SEE ALSO
L<Getopt::EX>, L<Getopt::EX::Loader>, L<Getopt::EX::Module>
L<App::optex::textconv>
L<App::optex::xform>
=head1 AUTHOR
Kazumasa Utashiro
=head1 LICENSE
You can redistribute it and/or modify it under the same terms
as Perl itself.
Copyright ©︎ 2017-2025 Kazumasa Utashiro
=cut
# LocalWords: optex rc iso greple awk pdf LANG ENV Oct JST domingo
# LocalWords: setenv autoload PERLLIB BINDIR Utashiro Kazumasa