Refactor most of the code

- Replace panic()-based error handling in main() with
   Exception-based error handling
 - Put hardcoded defaults in config.di
 - Make general optimisations and readability improvements
   for a lot of the code
 - Change some of the ESVApi's interface
This commit is contained in:
Jeremy Baxter 2023-09-23 17:10:38 +12:00
parent 1b11097ba0
commit 06a4dc286d
3 changed files with 397 additions and 362 deletions

331
main.d
View file

@ -19,123 +19,139 @@
*/
import std.conv : to, ConvException;
import std.file : exists, write, FileException;
import std.exception : enforce;
import std.file : exists, mkdirRecurse, write, FileException;
import std.format : format;
import std.getopt : getopt, GetOptException, config;
import std.path : baseName, expandTilde, isValidPath;
import std.getopt : getopt, GetOptException;
import std.getopt : getoptConfig = config;
import std.path : baseName, dirName, expandTilde, isValidPath;
import std.process : environment, executeShell;
import std.regex : regex, matchFirst, replaceAll, replaceFirst;
import std.stdio : writef, writeln, writefln, stderr;
import std.string : splitLines;
import config;
import esvapi;
import dini;
enum VERSION = "0.2.0";
enum DEFAULT_APIKEY = "abfb7456fa52ec4292c79e435890cfa3df14dc2b";
enum DEFAULT_CONFIGPATH = "~/.config/esv.conf";
enum DEFAULT_MPEGPLAYER = "mpg123";
enum DEFAULT_PAGER = "less";
bool aFlag; /* audio */
string CFlag; /* config */
bool fFlag, FFlag; /* footnotes */
bool hFlag, HFlag; /* headings */
int lFlag; /* line length */
bool nFlag, NFlag; /* verse numbers */
bool PFlag; /* disable pager */
bool rFlag, RFlag; /* passage references */
bool VFlag; /* show version */
enum ENV_CONFIG = "ESV_CONFIG";
enum ENV_PAGER = "ESV_PAGER";
enum ENV_PLAYER = "ESV_PLAYER";
bool optAudio;
string optConfigPath;
bool optFootnotes;
bool optNoFootnotes;
bool optHeadings;
bool optNoHeadings;
int optLineLength = 0;
bool optVerseNumbers;
bool optNoVerseNumbers;
bool optNoPager;
bool optPassageReferences;
bool optNoPassageReferences;
bool optVersion;
int main(string[] args)
int
main(string[] args)
{
void msg(string s)
{
stderr.writefln("%s: %s", args[0].baseName(), s);
bool success;
debug {
return run(args);
}
void panic(string s)
{
import core.runtime : Runtime;
import core.stdc.stdlib : exit;
msg(s);
scope(exit) {
Runtime.terminate();
exit(1);
try {
success = run(args);
} catch (Exception e) {
if (typeid(e) == typeid(Exception)) {
stderr.writefln("%s: %s", args[0].baseName(), e.msg);
} else {
stderr.writefln("%s: uncaught %s in %s:%d: %s",
args[0].baseName(),
typeid(e).name,
e.file, e.line, e.msg);
stderr.writefln("this is probably a bug; it would be greatly appreciated if you reported it at:\n\n %s",
BUGREPORTURL);
}
}
// Parse command-line options
return success ? 0 : 1;
}
bool
run(string[] args)
{
ushort lines;
string apiKey;
string configPath;
string configEnvVar;
string pager;
string verses;
Ini iniData;
ESVApi esv;
/* Parse command-line options */
try {
args.getopt(
config.bundling,
config.caseSensitive,
"a", &optAudio,
"C", &optConfigPath,
"F", &optNoFootnotes,
"f", &optFootnotes,
"H", &optNoHeadings,
"h", &optHeadings,
"l", &optLineLength,
"N", &optNoVerseNumbers,
"n", &optVerseNumbers,
"P", &optNoPager,
"R", &optNoPassageReferences,
"r", &optNoPassageReferences,
"V", &optVersion,
getoptConfig.bundling,
getoptConfig.caseSensitive,
"a", &aFlag,
"C", &CFlag,
"F", &FFlag,
"f", &fFlag,
"H", &HFlag,
"h", &hFlag,
"l", &lFlag,
"N", &NFlag,
"n", &nFlag,
"P", &PFlag,
"R", &RFlag,
"r", &rFlag,
"V", &VFlag,
);
} catch (GetOptException e) {
if (!e.msg.matchFirst(regex("^Unrecognized option")).empty)
panic("unknown option " ~ e.extractOpt());
else if (!e.msg.matchFirst(regex("^Missing value for argument")).empty)
panic("missing argument for option " ~ e.extractOpt());
} catch (ConvException e)
panic("value provided by option -l is not convertible to an integer value; must be a non-decimal number");
enforce(e.msg.matchFirst(regex("^Unrecognized option")).empty,
"unknown option " ~ e.extractOpt());
enforce(e.msg.matchFirst(regex("^Missing value for argument")).empty,
"missing argument for option " ~ e.extractOpt());
if (optVersion) {
throw new Exception(e.msg); /* catch-all */
} catch (ConvException e) {
throw new Exception(
"illegal argument to -l option -- integer required");
}
if (VFlag) {
writeln("esv version " ~ VERSION);
return 0;
return true;
}
if (args.length < 3) {
stderr.writefln("usage: %s [-C config] [-l length] [-aFfHhNnPRrV] book verses", args[0].baseName());
return 1;
return false;
}
// Determine configuration file
// Options have first priority, then environment variables, then the default path
string configPath;
string configEnvVar = environment.get(ENV_CONFIG);
Ini iniData;
enforce(bookValid(args[1].parseBook()),
format!"book '%s' does not exist"(args[1]));
enforce(verseValid(args[2]),
format!"invalid verse format '%s'"(args[2]));
/* determine configuration file
* Options have first priority, then environment variables,
* then the default path */
configPath = environment.get(ENV_CONFIG, DEFAULT_CONFIGPATH)
.expandTilde();
try {
if (optConfigPath != "") {
if (optConfigPath.isValidPath())
configPath = optConfigPath.expandTilde();
else
panic(optConfigPath ~ ": invalid file path");
if (CFlag != "") { /* if -C was given */
enforce(isValidPath(CFlag), CFlag ~ ": invalid path");
configPath = CFlag.expandTilde();
} else {
configPath = environment.get(ENV_CONFIG, DEFAULT_CONFIGPATH);
if (configPath.isValidPath())
configPath = configPath.expandTilde();
else
panic(configEnvVar ~ ": invalid file path");
enforce(isValidPath(configPath),
configPath ~ ": invalid path");
if (!configPath.exists()) {
configPath.write(
mkdirRecurse(configPath.dirName());
configPath.write(format!
"# Default esv configuration file.
# An API key is required to access the ESV Bible API.
[api]
key = " ~ DEFAULT_APIKEY ~ "
key = %s
# If you really need to, you can specify
# custom API parameters using `parameters`:
#parameters = &my-parameter=value
@ -146,136 +162,123 @@ key = " ~ DEFAULT_APIKEY ~ "
#headings = false
#passage_references = false
#verse_numbers = false
");
"(DEFAULT_APIKEY));
}
}
iniData = Ini.Parse(configPath);
} catch (FileException e) {
// filesystem syscall errors
if (!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Ii]s a directory")).empty ||
!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Nn]o such file or directory")).empty ||
!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Pp]ermission denied")).empty)
panic(e.msg);
/* filesystem syscall errors */
throw new Exception(e.msg);
}
string apiKey;
try
try {
apiKey = iniData["api"].getKey("key");
catch (IniException e)
panic("API key not present in configuration file; cannot proceed");
if (apiKey == "")
panic("API key not present in configuration file; cannot proceed");
} catch (IniException e) {
apiKey = "";
}
enforce(apiKey != "",
"API key not present in configuration file; cannot proceed");
// Initialise API object and validate the book and verse
ESVApi esv = new ESVApi(apiKey, optAudio);
if (!esv.validateBook(args[1].extractBook()))
panic("book '" ~ args[1] ~ "' does not exist");
if (!esv.validateVerse(args[2]))
panic("invalid verse format '" ~ args[2] ~ "'");
esv = new ESVApi(apiKey);
if (optAudio) {
// check for mpg123
if (executeShell("which " ~ DEFAULT_MPEGPLAYER ~ " >/dev/null 2>&1").status > 0) {
panic(DEFAULT_MPEGPLAYER ~ " is required for audio mode; cannot continue");
return 1;
} else {
string tmpf = esv.getAudioVerses(args[1], args[2]);
string mpegPlayer = environment.get(ENV_PLAYER, DEFAULT_MPEGPLAYER);
/*
* esv has built-in support for mpg123 and mpv;
* other players will work, just recompile with
* the DEFAULT_MPEGPLAYER enum set differently
* or use the ESV_PLAYER environment variable
*/
if (mpegPlayer == "mpg123")
mpegPlayer = mpegPlayer ~ " -q ";
else if (mpegPlayer == "mpv")
mpegPlayer = mpegPlayer ~ " --msg-level=all=no ";
else
mpegPlayer = DEFAULT_MPEGPLAYER ~ " ";
// spawn mpg123
executeShell(mpegPlayer ~ tmpf);
}
return 0;
if (aFlag) {
string tmpf, mpegPlayer;
/* check for mpg123 */
enforce(executeShell(
format!"command -v %s >/dev/null 2>&1"(DEFAULT_MPEGPLAYER)).status == 0,
DEFAULT_MPEGPLAYER ~ " is required for audio mode; cannot continue");
tmpf = esv.getAudioVerses(args[1], args[2]);
mpegPlayer = environment.get(ENV_PLAYER, DEFAULT_MPEGPLAYER);
/* esv has built-in support for mpg123 and mpv.
* other players will work, just recompile with
* the DEFAULT_MPEGPLAYER enum set differently
* or use the ESV_PLAYER environment variable */
mpegPlayer ~=
mpegPlayer == "mpg123" ? " -q " :
mpegPlayer == "mpv" ? " --msg-level=all=no " : " ";
/* spawn mpg123 */
executeShell(mpegPlayer ~ tmpf);
return true;
}
esv.extraParameters = iniData["api"].getKey("parameters");
string returnValid(string def, string val)
{
if (val == "")
return def;
else
return val;
return val == "" ? def : val;
}
// Get [passage] keys
/* Get [passage] keys */
foreach (string key; ["footnotes", "headings", "passage_references", "verse_numbers"]) {
try {
esv.opts.boolOpts["include_" ~ key] =
esv.opts.b["include_" ~ key] =
returnValid("true", iniData["passage"].getKey(key)).to!bool();
} catch (ConvException e) {
panic(
format!"%s: key '%s' of section 'passage' is not a boolean value (must be either 'true' or 'false')"(
configPath, key
)
throw new Exception(format!
"%s: key '%s' of section 'passage' is not a boolean value (must be either 'true' or 'false')"
(configPath, key)
);
} catch (IniException e) {} // just do nothing; use the default settings
}
// Get line_length ([passage])
try
esv.opts.intOpts["line_length"] = returnValid("0", iniData["passage"].getKey("line_length")).to!int();
/* Get line_length ([passage]) */
try esv.opts.i["line_length"] = returnValid("0", iniData["passage"].getKey("line_length")).to!int();
catch (ConvException e) {
panic(configPath ~ ": value '" ~ iniData["passage"].getKey("line_length")
~ "' is not convertible to an integer value; must be a non-decimal number");
throw new Exception(
format!"%s: illegal value '%s' -- must be an integer"(
configPath,
iniData["passage"].getKey("line_length"))
);
} catch (IniException e) {} // just do nothing; use the default setting
if (optFootnotes) esv.opts.boolOpts["include_footnotes"] = true;
if (optHeadings) esv.opts.boolOpts["include_headings"] = true;
if (optVerseNumbers) esv.opts.boolOpts["include_verse_numbers"] = true;
if (optPassageReferences) esv.opts.boolOpts["include_passage_references"] = true;
if (optNoFootnotes) esv.opts.boolOpts["include_footnotes"] = false;
if (optNoHeadings) esv.opts.boolOpts["include_headings"] = false;
if (optNoVerseNumbers) esv.opts.boolOpts["include_verse_numbers"] = false;
if (optNoPassageReferences) esv.opts.boolOpts["include_passage_references"] = false;
if (optLineLength != 0) esv.opts.intOpts ["line_length"] = optLineLength;
if (fFlag) esv.opts.b["include_footnotes"] = true;
if (hFlag) esv.opts.b["include_headings"] = true;
if (nFlag) esv.opts.b["include_verse_numbers"] = true;
if (rFlag) esv.opts.b["include_passage_references"] = true;
if (FFlag) esv.opts.b["include_footnotes"] = false;
if (HFlag) esv.opts.b["include_headings"] = false;
if (NFlag) esv.opts.b["include_verse_numbers"] = false;
if (RFlag) esv.opts.b["include_passage_references"] = false;
if (lFlag != 0) esv.opts.i["line_length"] = lFlag;
string verses = esv.getVerses(args[1].extractBook(), args[2]);
int lines;
verses = esv.getVerses(args[1].parseBook(), args[2]);
foreach (string line; verses.splitLines())
++lines;
// If the passage is very long, pipe it into a pager
if (lines > 32 && !optNoPager) {
/* If the passage is very long, pipe it into a pager */
if (lines > 32 && !PFlag) {
import std.process : pipeProcess, Redirect, wait, ProcessException;
string pager = environment.get(ENV_PAGER, DEFAULT_PAGER);
pager = environment.get(ENV_PAGER, DEFAULT_PAGER);
try {
auto pipe = pipeProcess(pager, Redirect.stdin);
pipe.stdin.writeln(verses);
pipe.stdin.flush();
pipe.stdin.close();
pipe.pid.wait();
}
catch (ProcessException e) {
if (!e.msg.matchFirst(regex("^Executable file not found")).empty) {
panic(e.msg
} catch (ProcessException e) {
enforce(e.msg.matchFirst(regex("^Executable file not found")).empty,
format!"%s: command not found"(e.msg
.matchFirst(": (.+)$")[0]
.replaceFirst(regex("^: "), "")
~ ": command not found"
);
}
}
} else
writeln(verses);
));
return 0;
throw new Exception(e.msg); /* catch-all */
}
return true;
}
writeln(verses);
return true;
}
string extractOpt(in GetOptException e) @safe
private string extractOpt(in GetOptException e) @safe
{
return e.msg.matchFirst("-.")[0];
}
string extractBook(in string book) @safe
private string parseBook(in string book) @safe
{
return book.replaceAll(regex("[-_]"), " ");
}