add helpdb, unit test, and document API

note that we have to dynamically sort some of the expected lists for
locale agnosticism
develop
myk002 2022-08-08 12:55:27 -07:00
parent 859414ac3b
commit 8672282fd8
No known key found for this signature in database
GPG Key ID: 8A39CA0FA0C16E78
6 changed files with 1574 additions and 0 deletions

@ -3068,6 +3068,87 @@ function:
argument specifies the indentation step size in spaces. For
the other arguments see the original documentation link above.
helpdb
======
Unified interface for DFHack tool help text. Help text is read from the rendered
text in ``hack/docs/docs/``. If no rendered text exists, help is read from the
script sources (for scripts) or the string passed to the ``PluginCommand``
initializer (for plugins). See `documentation` for details on how DFHack's help
system works.
The database is lazy-loaded when an API method is called. It rechecks its help
sources for updates if an API method has not been called in the last 60 seconds.
Each entry has several properties associated with it:
- The entry name, which is the name of a plugin, script, or command provided by
a plugin.
- The entry types, which can be ``builtin``, ``plugin``, and/or ``command``.
Entries for built-in commands (like ``ls`` or ``quicksave``) are both type
``builtin`` and ``command``. Entries named after plugins are type ``plugin``,
and if that plugin also provides a command with the same name as the plugin,
then the entry is also type ``command``. Entry types are returned as a map
of one or more of the type strings to ``true``.
- Short help, a the ~54 character description string.
- Long help, the entire contents of the associated help file.
- A list of tags that define the groups that the entry belongs to.
* ``helpdb.is_entry(str)``, ``helpdb.is_entry(list)``
Returns whether the given string (or list of strings) is an entry (are all
entries) in the db.
* ``helpdb.get_entry_types(entry)``
Returns the set (that is, a map of string to ``true``) of entry types for the
given entry.
* ``helpdb.get_entry_short_help(entry)``
Returns the short (~54 character) description for the given entry.
* ``helpdb.get_entry_long_help(entry)``
Returns the full help text for the given entry.
* ``helpdb.get_entry_tags(entry)``
Returns the set of tag names for the given entry.
* ``helpdb.is_tag(str)``, ``helpdb.is_tag(list)``
Returns whether the given string (or list of strings) is a (are all) valid tag
name(s).
* ``helpdb.get_tags()``
Returns the full alphabetized list of valid tag names.
* ``helpdb.get_tag_data(tag)``
Returns a list of entries that have the given tag. The returned table also
has a ``description`` key that contains the string description of the tag.
* ``helpdb.search_entries([include[, exclude]])``
Returns a list of names for entries that match the given filters. The list is
alphabetized by their last path component, with populated path components
coming before null path components (e.g. ``autobutcher`` will immediately
follow ``gui/autobutcher``).
The optional ``include`` and ``exclude`` filter params are maps with the
following elements:
:str: if a string, filters by the given substring. if a table of strings,
includes entry names that match any of the given substrings.
:tag: if a string, filters by the given tag name. if a table of strings,
includes entries that match any of the given tags.
:entry_type: if a string, matches entries of the given type. if a table of
strings, includes entries that match any of the given types.
If ``include`` is ``nil`` or empty, then all entries are included. If
``exclude`` is ``nil`` or empty, then no entries are filtered out.
profiler
========

@ -67,6 +67,7 @@ changelog.txt uses a syntax similar to RST, with a few special sequences:
## Lua
- History: added ``dfhack.getCommandHistory(history_id, history_filename)`` and ``dfhack.addCommandToHistory(history_id, history_filename, command)`` so gui scripts can access a commandline history without requiring a terminal.
- ``helpdb``: database and query interface for DFHack tool help text
- ``tile-material``: fix the order of declarations. The ``GetTileMat`` function now returns the material as intended (always returned nil before). Also changed the license info, with permission of the original author.
- ``widgets.EditField``: new ``onsubmit2`` callback attribute is called when the user hits Shift-Enter.
- ``widgets.EditField``: new function: ``setCursor(position)`` sets the input cursor.

@ -148,6 +148,16 @@ void Lua::Push(lua_State *state, df::coord2d pos)
lua_setfield(state, -2, "y");
}
void Lua::GetVector(lua_State *state, std::vector<std::string> &pvec)
{
lua_pushnil(state); // first key
while (lua_next(state, 1) != 0)
{
pvec.push_back(lua_tostring(state, -1));
lua_pop(state, 1); // remove value, leave key
}
}
int Lua::PushPosXYZ(lua_State *state, df::coord pos)
{
if (!pos.isValid())
@ -3061,6 +3071,99 @@ static int internal_findScript(lua_State *L)
return 1;
}
static int internal_listPlugins(lua_State *L)
{
auto plugins = Core::getInstance().getPluginManager();
int i = 1;
lua_newtable(L);
for (auto it = plugins->begin(); it != plugins->end(); ++it)
{
lua_pushinteger(L, i++);
lua_pushstring(L, it->first.c_str());
lua_settable(L, -3);
}
return 1;
}
static int internal_listCommands(lua_State *L)
{
auto plugins = Core::getInstance().getPluginManager();
const char *name = luaL_checkstring(L, 1);
auto plugin = plugins->getPluginByName(name);
if (!plugin)
{
lua_pushnil(L);
return 1;
}
size_t num_commands = plugin->size();
lua_newtable(L);
for (size_t i = 0; i < num_commands; ++i)
{
lua_pushinteger(L, i + 1);
lua_pushstring(L, (*plugin)[i].name.c_str());
lua_settable(L, -3);
}
return 1;
}
static const PluginCommand * getPluginCommand(const char * command)
{
auto plugins = Core::getInstance().getPluginManager();
auto plugin = plugins->getPluginByCommand(command);
if (!plugin)
{
return NULL;
}
size_t num_commands = plugin->size();
for (size_t i = 0; i < num_commands; ++i)
{
if ((*plugin)[i].name == command)
return &(*plugin)[i];
}
// not found (somehow)
return NULL;
}
static int internal_getCommandHelp(lua_State *L)
{
const PluginCommand *pc = getPluginCommand(luaL_checkstring(L, 1));
if (!pc)
{
lua_pushnil(L);
return 1;
}
std::string help = pc->description;
if (help.size() && help[help.size()-1] != '.')
help += ".";
if (pc->usage.size())
help += "\n" + pc->usage;
lua_pushstring(L, help.c_str());
return 1;
}
static int internal_getCommandDescription(lua_State *L)
{
const PluginCommand *pc = getPluginCommand(luaL_checkstring(L, 1));
if (!pc)
{
lua_pushnil(L);
return 1;
}
std::string help = pc->description;
if (help.size() && help[help.size()-1] != '.')
help += ".";
lua_pushstring(L, help.c_str());
return 1;
}
static int internal_threadid(lua_State *L)
{
std::stringstream ss;
@ -3132,6 +3235,10 @@ static const luaL_Reg dfhack_internal_funcs[] = {
{ "removeScriptPath", internal_removeScriptPath },
{ "getScriptPaths", internal_getScriptPaths },
{ "findScript", internal_findScript },
{ "listPlugins", internal_listPlugins },
{ "listCommands", internal_listCommands },
{ "getCommandHelp", internal_getCommandHelp },
{ "getCommandDescription", internal_getCommandDescription },
{ "threadid", internal_threadid },
{ "md5File", internal_md5file },
{ NULL, NULL }

@ -339,6 +339,8 @@ namespace DFHack {namespace Lua {
}
}
DFHACK_EXPORT void GetVector(lua_State *state, std::vector<std::string> &pvec);
DFHACK_EXPORT int PushPosXYZ(lua_State *state, df::coord pos);
DFHACK_EXPORT int PushPosXY(lua_State *state, df::coord2d pos);

@ -0,0 +1,735 @@
-- The help text database and query interface.
--
-- Help text is read from the rendered text in hack/docs/docs/. If no rendered
-- text exists, it is read from the script sources (for scripts) or the string
-- passed to the PluginCommand initializer (for plugins).
--
-- There should be one help file for each plugin that contains a summary for the
-- plugin itself and help for all the commands that plugin provides (if any).
-- Each script should also have one documentation file.
--
-- The database is lazy-loaded when an API method is called. It rechecks its
-- help sources for updates if an API method has not been called in the last
-- 60 seconds.
local _ENV = mkmodule('helpdb')
local MAX_STALE_MS = 60000
-- paths
local RENDERED_PATH = 'hack/docs/docs/tools/'
local TAG_DEFINITIONS = 'hack/docs/docs/Tags.txt'
-- used when reading help text embedded in script sources
local SCRIPT_DOC_BEGIN = '[====['
local SCRIPT_DOC_END = ']====]'
local SCRIPT_DOC_BEGIN_RUBY = '=begin'
local SCRIPT_DOC_END_RUBY = '=end'
-- enums
local ENTRY_TYPES = {
BUILTIN='builtin',
PLUGIN='plugin',
COMMAND='command'
}
local HELP_SOURCES = {
RENDERED='rendered', -- from the installed, rendered help text
PLUGIN='plugin', -- from the plugin source code
SCRIPT='script', -- from the script source code
STUB='stub', -- from a generated stub
}
-- builtin command names, with aliases mapped to their canonical form
local BUILTINS = {
['?']='help',
alias=true,
clear='cls',
cls=true,
['devel/dump-rpc']=true,
die=true,
dir='ls',
disable=true,
enable=true,
fpause=true,
help=true,
hide=true,
keybinding=true,
['kill-lua']=true,
['load']=true,
ls=true,
man='help',
plug=true,
reload=true,
script=true,
['sc-script']=true,
show=true,
tags=true,
['type']=true,
unload=true,
}
---------------------------------------------------------------------------
-- data structures
---------------------------------------------------------------------------
-- help text database, keys are a subset of the entry database
-- entry name -> {
-- help_source (element of HELP_SOURCES),
-- short_help (string),
-- long_help (string),
-- tags (set),
-- source_timestamp (mtime, 0 for non-files),
-- source_path (string, nil for non-files)
-- }
local textdb = {}
-- entry database, points to text in textdb
-- entry name -> {
-- entry_types (set of ENTRY_TYPES),
-- short_help (string, if not nil then overrides short_help in text_entry),
-- text_entry (string)
-- }
--
-- entry_types is a set because plugin commands can also be the plugin names.
local entrydb = {}
-- tag name -> list of entry names
-- Tags defined in the TAG_DEFINITIONS file that have no associated db entries
-- will have an empty list.
local tag_index = {}
---------------------------------------------------------------------------
-- data ingestion
---------------------------------------------------------------------------
local function get_rendered_path(entry_name)
return RENDERED_PATH .. entry_name .. '.txt'
end
local function has_rendered_help(entry_name)
return dfhack.filesystem.mtime(get_rendered_path(entry_name)) ~= -1
end
local DEFAULT_HELP_TEMPLATE = [[
%s
%s
No help available.
]]
local function make_default_entry(entry_name, help_source, kwargs)
local default_long_help = DEFAULT_HELP_TEMPLATE:format(
entry_name, ('*'):rep(#entry_name))
return {
help_source=help_source,
short_help='No help available.',
long_help=default_long_help,
tags={},
source_timestamp=kwargs.source_timestamp or 0,
source_path=kwargs.source_path}
end
-- updates the short_text, the long_text, and the tags in the given entry based
-- on the text returned from the iterator.
-- if defined, opts can have the following fields:
-- begin_marker (string that marks the beginning of the help text; all text
-- before this marker is ignored)
-- end_marker (string that marks the end of the help text; text will stop
-- being parsed after this marker is seen)
-- no_header (don't try to find the entity name at the top of the help text)
-- first_line_is_short_help (if set, then read the short help text from the
-- first commented line of the script instead of
-- using the first sentence of the long help text.
-- value is the comment character.)
local function update_entry(entry, iterator, opts)
opts = opts or {}
local lines, tags = {}, ''
local first_line_is_short_help = opts.first_line_is_short_help
local begin_marker_found,header_found = not opts.begin_marker,opts.no_header
local tags_found, short_help_found = false, opts.skip_short_help
local in_tags, in_short_help = false, false
for line in iterator do
if not short_help_found and first_line_is_short_help then
line = line:trim()
local _,_,text = line:find('^'..first_line_is_short_help..'%s*(.*)')
if not text then
-- if no first-line short help found, fall back to getting the
-- first sentence of the help text.
first_line_is_short_help = false
else
if not text:endswith('.') then
text = text .. '.'
end
entry.short_help = text
short_help_found = true
goto continue
end
end
if not begin_marker_found then
local _, endpos = line:find(opts.begin_marker, 1, true)
if endpos == #line then
begin_marker_found = true
end
goto continue
end
if opts.end_marker then
local _, endpos = line:find(opts.end_marker, 1, true)
if endpos == #line then
break
end
end
if not header_found and line:find('%w') then
header_found = true
elseif in_tags then
if #line == 0 then
in_tags = false
else
tags = tags .. line
end
elseif not tags_found and line:find('^[*]*Tags:[*]*') then
_,_,tags = line:trim():find('[*]*Tags:[*]* *(.*)')
in_tags, tags_found = true, true
elseif not short_help_found and
line:find('^%w') then
if in_short_help then
entry.short_help = entry.short_help .. ' ' .. line
else
entry.short_help = line
end
local sentence_end = entry.short_help:find('.', 1, true)
if sentence_end then
entry.short_help = entry.short_help:sub(1, sentence_end)
short_help_found = true
else
in_short_help = true
end
end
table.insert(lines, line)
::continue::
end
entry.tags = {}
for _,tag in ipairs(tags:split('[ ,|]+')) do
if #tag > 0 and tag_index[tag] then
entry.tags[tag] = true
end
end
if #lines > 0 then
entry.long_help = table.concat(lines, '\n')
end
end
-- create db entry based on parsing sphinx-rendered help text
local function make_rendered_entry(old_entry, entry_name, kwargs)
local source_path = get_rendered_path(entry_name)
local source_timestamp = dfhack.filesystem.mtime(source_path)
if old_entry and old_entry.help_source == HELP_SOURCES.RENDERED and
old_entry.source_timestamp >= source_timestamp then
-- we already have the latest info
return old_entry
end
kwargs.source_path, kwargs.source_timestamp = source_path, source_timestamp
local entry = make_default_entry(entry_name, HELP_SOURCES.RENDERED, kwargs)
local ok, lines = pcall(io.lines, source_path)
if not ok then
return entry
end
update_entry(entry, lines)
return entry
end
-- create db entry based on the help text in the plugin source (used by
-- out-of-tree plugins)
local function make_plugin_entry(old_entry, entry_name, kwargs)
if old_entry and old_entry.source == HELP_SOURCES.PLUGIN then
-- we can't tell when a plugin is reloaded, so we can either choose to
-- always refresh or never refresh. let's go with never for now for
-- performance.
return old_entry
end
local entry = make_default_entry(entry_name, HELP_SOURCES.PLUGIN, kwargs)
local long_help = dfhack.internal.getCommandHelp(entry_name)
if long_help and #long_help:trim() > 0 then
update_entry(entry, long_help:trim():gmatch('[^\n]*'), {no_header=true})
end
return entry
end
-- create db entry based on the help text in the script source (used by
-- out-of-tree scripts)
local function make_script_entry(old_entry, entry_name, kwargs)
local source_path = kwargs.source_path
local source_timestamp = dfhack.filesystem.mtime(source_path)
if old_entry and old_entry.source == HELP_SOURCES.SCRIPT and
old_entry.source_path == source_path and
old_entry.source_timestamp >= source_timestamp then
-- we already have the latest info
return old_entry
end
kwargs.source_timestamp, kwargs.entry_type = source_timestamp
local entry = make_default_entry(entry_name, HELP_SOURCES.SCRIPT, kwargs)
local ok, lines = pcall(io.lines, source_path)
if not ok then
return entry
end
local is_rb = source_path:endswith('.rb')
update_entry(entry, lines,
{begin_marker=(is_rb and SCRIPT_DOC_BEGIN_RUBY or SCRIPT_DOC_BEGIN),
end_marker=(is_rb and SCRIPT_DOC_BEGIN_RUBY or SCRIPT_DOC_END),
first_line_is_short_help=(is_rb and '#' or '%-%-')})
return entry
end
-- updates the dbs (and associated tag index) with a new entry if the entry_name
-- doesn't already exist in the dbs.
local function update_db(old_db, entry_name, text_entry, help_source, kwargs)
if entrydb[entry_name] then
-- already in db (e.g. from a higher-priority script dir); skip
return
end
entrydb[entry_name] = {
entry_types=kwargs.entry_types,
short_help=kwargs.short_help,
text_entry=text_entry
}
if entry_name ~= text_entry then
return
end
local text_entry, old_entry = nil, old_db[entry_name]
if help_source == HELP_SOURCES.RENDERED then
text_entry = make_rendered_entry(old_entry, entry_name, kwargs)
elseif help_source == HELP_SOURCES.PLUGIN then
text_entry = make_plugin_entry(old_entry, entry_name, kwargs)
elseif help_source == HELP_SOURCES.SCRIPT then
text_entry = make_script_entry(old_entry, entry_name, kwargs)
elseif help_source == HELP_SOURCES.STUB then
text_entry = make_default_entry(entry_name, HELP_SOURCES.STUB, kwargs)
else
error('unhandled help source: ' .. help_source)
end
textdb[entry_name] = text_entry
end
-- add the builtin commands to the db
local function scan_builtins(old_db)
local entry_types = {[ENTRY_TYPES.BUILTIN]=true, [ENTRY_TYPES.COMMAND]=true}
for builtin,canonical in pairs(BUILTINS) do
if canonical == true then canonical = builtin end
update_db(old_db, builtin, canonical,
has_rendered_help(canonical) and
HELP_SOURCES.RENDERED or HELP_SOURCES.STUB,
{entry_types=entry_types})
end
end
-- scan for enableable plugins and plugin-provided commands and add their help
-- to the db
local function scan_plugins(old_db)
local plugin_names = dfhack.internal.listPlugins()
for _,plugin in ipairs(plugin_names) do
local commands = dfhack.internal.listCommands(plugin)
local includes_plugin = false
for _,command in ipairs(commands) do
local kwargs = {entry_types={[ENTRY_TYPES.COMMAND]=true}}
if command == plugin then
kwargs.entry_types[ENTRY_TYPES.PLUGIN]=true
includes_plugin = true
end
kwargs.short_help = dfhack.internal.getCommandDescription(command)
update_db(old_db, command, plugin,
has_rendered_help(plugin) and
HELP_SOURCES.RENDERED or HELP_SOURCES.PLUGIN,
kwargs)
end
if not includes_plugin then
update_db(old_db, plugin, plugin,
has_rendered_help(plugin) and
HELP_SOURCES.RENDERED or HELP_SOURCES.STUB,
{entry_types={[ENTRY_TYPES.PLUGIN]=true}})
end
end
end
-- scan for scripts and add their help to the db
local function scan_scripts(old_db)
local entry_types = {[ENTRY_TYPES.COMMAND]=true}
for _,script_path in ipairs(dfhack.internal.getScriptPaths()) do
local files = dfhack.filesystem.listdir_recursive(
script_path, nil, false)
if not files then goto skip_path end
for _,f in ipairs(files) do
if f.isdir or
(not f.path:endswith('.lua') and not f.path:endswith('.rb')) or
f.path:startswith('test/') or
f.path:startswith('internal/') then
goto continue
end
local dot_index = f.path:find('%.[^.]*$')
local entry_name = f.path:sub(1, dot_index - 1)
local source_path = script_path .. '/' .. f.path
update_db(old_db, entry_name, entry_name,
has_rendered_help(entry_name) and
HELP_SOURCES.RENDERED or HELP_SOURCES.SCRIPT,
{entry_types=entry_types, source_path=source_path})
::continue::
end
::skip_path::
end
end
-- read tags and descriptions from the TAG_DEFINITIONS file and add them all
-- to tag_index, initizlizing each entry with an empty list.
local function initialize_tags()
local tag, desc, in_desc = nil, nil, false
local ok, lines = pcall(io.lines, TAG_DEFINITIONS)
if not ok then return end
for line in lines do
if in_desc then
line = line:trim()
if #line == 0 then
in_desc = false
goto continue
end
desc = desc .. ' ' .. line
tag_index[tag].description = desc
else
_,_,tag,desc = line:find('^%* (%w+): (.+)')
if not tag then goto continue end
tag_index[tag] = {description=desc}
in_desc = true
end
::continue::
end
end
local function index_tags()
for entry_name,entry in pairs(entrydb) do
for tag in pairs(textdb[entry.text_entry].tags) do
-- ignore unknown tags
if tag_index[tag] then
table.insert(tag_index[tag], entry_name)
end
end
end
end
-- ensures the db is up to date by scanning all help sources. does not do
-- anything if it has already been run within the last MAX_STALE_MS milliseconds
local last_refresh_ms = 0
local function ensure_db()
local now_ms = dfhack.getTickCount()
if now_ms - last_refresh_ms <= MAX_STALE_MS then return end
last_refresh_ms = now_ms
local old_db = textdb
textdb, entrydb, tag_index = {}, {}, {}
initialize_tags()
scan_builtins(old_db)
scan_plugins(old_db)
scan_scripts(old_db)
index_tags()
end
---------------------------------------------------------------------------
-- get API
---------------------------------------------------------------------------
-- converts strings into single-element lists containing that string
local function normalize_string_list(l)
if not l or #l == 0 then return nil end
if type(l) == 'string' then
return {l}
end
return l
end
local function has_keys(str, dict)
if not str or #str == 0 then
return false
end
for _,s in ipairs(normalize_string_list(str)) do
if not dict[s] then
return false
end
end
return true
end
-- returns whether the given string (or list of strings) is an entry (are all
-- entries) in the db
function is_entry(str)
ensure_db()
return has_keys(str, entrydb)
end
local function get_db_property(entry_name, property)
ensure_db()
if not entrydb[entry_name] then
error(('helpdb entry not found: "%s"'):format(entry_name))
end
return entrydb[entry_name][property] or
textdb[entrydb[entry_name].text_entry][property]
end
function get_entry_types(entry)
return get_db_property(entry, 'entry_types')
end
-- returns the ~54 char summary blurb associated with the entry
function get_entry_short_help(entry)
return get_db_property(entry, 'short_help')
end
-- returns the full help documentation associated with the entry
function get_entry_long_help(entry)
return get_db_property(entry, 'long_help')
end
-- returns the set of tags associated with the entry
function get_entry_tags(entry)
return get_db_property(entry, 'tags')
end
-- returns whether the given string (or list of strings) matches a tag name
function is_tag(str)
ensure_db()
return has_keys(str, tag_index)
end
local function set_to_sorted_list(set)
local list = {}
for item in pairs(set) do
table.insert(list, item)
end
table.sort(list)
return list
end
-- returns the defined tags in alphabetical order
function get_tags()
ensure_db()
return set_to_sorted_list(tag_index)
end
function get_tag_data(tag)
ensure_db()
if not tag_index[tag] then
error(('helpdb tag not found: "%s"'):format(tag))
end
return tag_index[tag]
end
---------------------------------------------------------------------------
-- search API
---------------------------------------------------------------------------
-- returns a list of path elements in reverse order
local function chunk_for_sorting(str)
local parts = str:split('/')
local chunks = {}
for i=1,#parts do
chunks[#parts - i + 1] = parts[i]
end
return chunks
end
-- sorts by last path component, then by parent path components.
-- something comes before nothing.
-- e.g. gui/autofarm comes immediately before autofarm
function sort_by_basename(a, b)
local a = chunk_for_sorting(a)
local b = chunk_for_sorting(b)
local i = 1
while a[i] do
if not b[i] then
return true
end
if a[i] ~= b[i] then
return a[i] < b[i]
end
i = i + 1
end
return false
end
local function matches(entry_name, filter)
if filter.tag then
local matched = false
local tags = get_db_property(entry_name, 'tags')
for _,tag in ipairs(filter.tag) do
if tags[tag] then
matched = true
break
end
end
if not matched then
return false
end
end
if filter.entry_type then
local matched = false
local etypes = get_db_property(entry_name, 'entry_types')
for _,etype in ipairs(filter.entry_type) do
if etypes[etype] then
matched = true
break
end
end
if not matched then
return false
end
end
if filter.str then
local matched = false
for _,str in ipairs(filter.str) do
if entry_name:find(str, 1, true) then
matched = true
break
end
end
if not matched then
return false
end
end
return true
end
-- normalizes the lists in the filter and returns nil if no filter elements are
-- populated
local function normalize_filter(f)
if not f then return nil end
local filter = {}
filter.str = normalize_string_list(f.str)
filter.tag = normalize_string_list(f.tag)
filter.entry_type = normalize_string_list(f.entry_type)
if not filter.str and not filter.tag and not filter.entry_type then
return nil
end
return filter
end
-- returns a list of entry names, alphabetized by their last path component,
-- with populated path components coming before null path components (e.g.
-- autobutcher will immediately follow gui/autobutcher).
-- the optional include and exclude filter params are maps with the following
-- elements:
-- str - if a string, filters by the given substring. if a table of strings,
-- includes entry names that match any of the given substrings.
-- tag - if a string, filters by the given tag name. if a table of strings,
-- includes entries that match any of the given tags.
-- entry_type - if a string, matches entries of the given type. if a table of
-- strings, includes entries that match any of the given types. valid
-- types are: "builtin", "plugin", "command". note that many plugin
-- commands have the same name as the plugin, so those entries will
-- match both "plugin" and "command" types.
function search_entries(include, exclude)
ensure_db()
include = normalize_filter(include)
exclude = normalize_filter(exclude)
local entries = {}
for entry in pairs(entrydb) do
if (not include or matches(entry, include)) and
(not exclude or not matches(entry, exclude)) then
table.insert(entries, entry)
end
end
table.sort(entries, sort_by_basename)
return entries
end
-- returns a list of all commands. used by Core's autocomplete functionality.
function get_commands()
local include = {entry_type=ENTRY_TYPES.COMMAND}
return search_entries(include)
end
function is_builtin(command)
return is_entry(command) and get_entry_types(command)[ENTRY_TYPES.BUILTIN]
end
---------------------------------------------------------------------------
-- print API (outputs to console)
---------------------------------------------------------------------------
-- implements the 'help' builtin command
function help(entry)
ensure_db()
if not entrydb[entry] then
dfhack.printerr(('No help entry found for "%s"'):format(entry))
return
end
print(get_entry_long_help(entry))
end
-- prints col1text (width 21), a one space gap, and col2 (width 58)
-- if col1text is longer than 21 characters, col2text is printed starting on the
-- next line. if col2text is longer than 58 characters, it is wrapped. col2text
-- lines on lines below the col1text output are indented by one space further
-- than the col2text on the first line.
local COL1WIDTH, COL2WIDTH = 20, 58
local function print_columns(col1text, col2text)
col2text = col2text:wrap(COL2WIDTH)
local wrapped_col2 = {}
for line in col2text:gmatch('[^'..NEWLINE..']*') do
table.insert(wrapped_col2, line)
end
local col2_start_line = 1
if #col1text > COL1WIDTH then
print(col1text)
else
print(('%-'..COL1WIDTH..'s %s'):format(col1text, wrapped_col2[1]))
col2_start_line = 2
end
for i=col2_start_line,#wrapped_col2 do
print(('%'..COL1WIDTH..'s %s'):format(' ', wrapped_col2[i]))
end
end
-- implements the 'tags' builtin command
function tags()
local tags = get_tags()
for _,tag in ipairs(tags) do
print_columns(tag, get_tag_data(tag).description)
end
end
-- prints the requested entries to the console. include and exclude filters are
-- defined as in search_entries() above.
local function list_entries(skip_tags, include, exclude)
local entries = search_entries(include, exclude)
for _,entry in ipairs(entries) do
print_columns(entry, get_entry_short_help(entry))
if not skip_tags then
local tags = set_to_sorted_list(get_entry_tags(entry))
if #tags > 0 then
print((' tags: %s'):format(table.concat(tags, ', ')))
end
end
end
if #entries == 0 then
print('No matches.')
end
end
-- wraps the list_entries() API to provide a more convenient interface for Core
-- to implement the 'ls' builtin command.
-- filter_str - if a tag name, will filter by that tag. otherwise, will filter
-- as a substring
-- skip_tags - whether to skip printing tag info
-- show_dev_commands - if true, will include scripts in the modtools/ and
-- devel/ directories. otherwise those scripts will be
-- excluded
function ls(filter_str, skip_tags, show_dev_commands)
local include = {entry_type={ENTRY_TYPES.COMMAND}}
if is_tag(filter_str) then
include.tag = filter_str
else
include.str = filter_str
end
list_entries(skip_tags, include,
show_dev_commands and {} or {tag='dev'})
end
return _ENV

@ -0,0 +1,648 @@
local h = require('helpdb')
local mock_plugin_db = {
hascommands={
boxbinders={description="Box your binders.", help=[[Box your binders.
This command will help you box your binders.]]},
bindboxers={description="Bind your boxers.", help=[[Bind your boxers.
This command will help you bind your boxers.]]}
},
samename={
samename={description="Samename.", help=[[Samename.
This command has the same name as its host plugin.]]}
},
nocommand={},
nodocs_hascommands={
nodoc_command={description="cpp description.", help=[[cpp description.
Rest of help.]]}
},
nodocs_samename={
nodocs_samename={description="Nodocs samename.", help=[[Nodocs samename.
This command has the same name as its host plugin but no rst docs.]]}
},
nodocs_nocommand={},
}
local mock_command_db = {}
for k,v in pairs(mock_plugin_db) do
for c,d in pairs(v) do
mock_command_db[c] = d
end
end
local mock_script_db = {
basic=true,
['subdir/scriptname']=true,
inscript_docs=true,
inscript_short_only=true,
nodocs_script=true,
}
local files = {
['hack/docs/docs/Tags.txt']=[[
* fort: Tools that are useful while in fort mode.
* armok: Tools that give you complete control over
an aspect of the game or provide access to
information that the game intentionally keeps
hidden.
* map: Tools that interact with the game map.
* units: Tools that interact with units.
* nomembers: Nothing is tagged with this.
]],
['hack/docs/docs/tools/hascommands.txt']=[[
hascommands
***********
**Tags:** fort | armok | units
Documented a plugin that
has commands.
**Command:** "boxbinders"
Documented boxbinders.
**Command:** "bindboxers"
Documented bindboxers.
Documented full help.
]],
['hack/docs/docs/tools/samename.txt']=[[
samename
********
**Tags:** fort | armok
| units
**Command:** "samename"
Documented samename.
Documented full help.
]],
['hack/docs/docs/tools/nocommand.txt']=[[
nocommand
*********
**Tags:** fort | armok |
units
Documented nocommand.
Documented full help.
]],
['hack/docs/docs/tools/basic.txt']=[[
basic
*****
**Tags:** map
**Command:** "basic"
Documented basic.
Documented full help.
]],
['hack/docs/docs/tools/subdir/scriptname.txt']=[[
subdir/scriptname
*****************
**Tags:** map
**Command:** "subdir/scriptname"
Documented subdir/scriptname.
Documented full help.
]],
['scripts/scriptpath/basic.lua']=[[
-- in-file short description for basic
-- [====[
basic
=====
**Tags:** map
**Command:** "basic"
in-file basic.
Documented full help.
]====]
script contents
]],
['scripts/scriptpath/subdir/scriptname.lua']=[[
-- in-file short description for scriptname
-- [====[
subdir/scriptname
=================
**Tags:** map
**Command:** "subdir/scriptname"
in-file scriptname.
Documented full help.
]====]
script contents
]],
['scripts/scriptpath/inscript_docs.lua']=[[
-- in-file short description for inscript_docs
-- [====[
inscript_docs
=============
**Tags:** map | badtag
**Command:** "inscript_docs"
in-file inscript_docs.
Documented full help.
]====]
script contents
]],
['scripts/scriptpath/nodocs_script.lua']=[[
script contents
]],
['scripts/scriptpath/inscript_short_only.lua']=[[
-- inscript short desc.
script contents
]],
['other/scriptpath/basic.lua']=[[
-- in-file short description for basic (other)
-- [====[
basic
=====
**Tags:** map
**Command:** "basic"
in-file basic (other).
Documented full help.
]====]
script contents
]],
['other/scriptpath/subdir/scriptname.lua']=[[
-- in-file short description for scriptname (other)
-- [====[
subdir/scriptname
=================
**Tags:** map
**Command:** "subdir/scriptname"
in-file scriptname (other).
Documented full help.
]====]
script contents
]],
['other/scriptpath/inscript_docs.lua']=[[
-- in-file short description for inscript_docs (other)
-- [====[
inscript_docs
=============
**Tags:** map
**Command:** "inscript_docs"
in-file inscript_docs (other).
Documented full help.
]====]
script contents
]],
}
local function mock_getCommandHelp(command)
if mock_command_db[command] then
return mock_command_db[command].help
end
end
local function mock_listPlugins()
local list = {}
for k in pairs(mock_plugin_db) do
table.insert(list, k)
end
return list
end
local function mock_listCommands(plugin)
local list = {}
for k,v in pairs(mock_plugin_db) do
if k == plugin then
for c in pairs(v) do
table.insert(list, c)
end
break
end
end
return list
end
local function mock_getCommandDescription(command)
if mock_command_db[command] then
return mock_command_db[command].description
end
end
local function mock_getScriptPaths()
return {'scripts/scriptpath', 'other/scriptpath'}
end
local function mock_mtime(path)
if files[path] then return 1 end
return -1
end
local function mock_listdir_recursive(script_path)
local list = {}
for s in pairs(mock_script_db) do
table.insert(list, {isdir=false, path=s..'.lua'})
end
return list
end
local function mock_getTickCount()
return 100000
end
local function mock_pcall(fn, fname)
if fn ~= io.lines then error('unexpected fn for pcall') end
if not files[fname] then
return false
end
return true, files[fname]:gmatch('([^\n]*)\n?')
end
config.wrapper = function(test_fn)
mock.patch({
{h.dfhack.internal, 'getCommandHelp', mock_getCommandHelp},
{h.dfhack.internal, 'listPlugins', mock_listPlugins},
{h.dfhack.internal, 'listCommands', mock_listCommands},
{h.dfhack.internal, 'getCommandDescription', mock_getCommandDescription},
{h.dfhack.internal, 'getScriptPaths', mock_getScriptPaths},
{h.dfhack.filesystem, 'mtime', mock_mtime},
{h.dfhack.filesystem, 'listdir_recursive', mock_listdir_recursive},
{h.dfhack, 'getTickCount', mock_getTickCount},
{h, 'pcall', mock_pcall},
}, test_fn)
end
function test.is_entry()
expect.true_(h.is_entry('ls'),
'builtin commands get an entry')
expect.true_(h.is_entry('hascommands'),
'plugins whose names do not match their commands get an entry')
expect.true_(h.is_entry('boxbinders'),
'commands whose name does not match the host plugin get an entry')
expect.true_(h.is_entry('samename'),
'plugins that have a command with the same name get one entry')
expect.true_(h.is_entry('nocommand'),
'plugins that do not have commands get an entry')
expect.true_(h.is_entry('basic'),
'scripts in the script path get an entry')
expect.true_(h.is_entry('subdir/scriptname'),
'scripts in subdirs of a script path get an entry')
expect.true_(h.is_entry('nodocs_hascommands'),
'plugins whose names do not match their commands get an entry')
expect.true_(h.is_entry('nodoc_command'),
'commands whose name does not match the host plugin get an entry')
expect.true_(h.is_entry('nodocs_samename'),
'plugins that have a command with the same name get one entry')
expect.true_(h.is_entry('nodocs_nocommand'),
'plugins that do not have commands get an entry')
expect.true_(h.is_entry('inscript_docs'),
'scripts in the script path get an entry')
expect.true_(h.is_entry('nodocs_script'),
'scripts in the script path get an entry')
expect.true_(h.is_entry('inscript_short_only'),
'scripts in the script path get an entry')
expect.false_(h.is_entry(nil),
'nil is not an entry')
expect.false_(h.is_entry(''),
'blank is not an entry')
expect.false_(h.is_entry('notanentryname'),
'strings that are neither plugin names nor command names do not get an entry')
expect.true_(h.is_entry({'hascommands', 'boxbinders', 'nocommand'}),
'list of valid entries')
expect.false_(h.is_entry({'hascommands', 'notanentryname'}),
'list contains an element that is not an entry')
end
function test.get_entry_types()
expect.table_eq({builtin=true, command=true}, h.get_entry_types('ls'))
expect.table_eq({plugin=true}, h.get_entry_types('hascommands'))
expect.table_eq({command=true}, h.get_entry_types('boxbinders'))
expect.table_eq({plugin=true, command=true}, h.get_entry_types('samename'))
expect.table_eq({plugin=true}, h.get_entry_types('nocommand'))
expect.table_eq({command=true}, h.get_entry_types('basic'))
expect.table_eq({command=true}, h.get_entry_types('subdir/scriptname'))
expect.table_eq({plugin=true}, h.get_entry_types('nodocs_hascommands'))
expect.table_eq({command=true}, h.get_entry_types('nodoc_command'))
expect.table_eq({plugin=true, command=true}, h.get_entry_types('nodocs_samename'))
expect.table_eq({plugin=true}, h.get_entry_types('nodocs_nocommand'))
expect.table_eq({command=true}, h.get_entry_types('nodocs_script'))
expect.table_eq({command=true}, h.get_entry_types('inscript_docs'))
expect.table_eq({command=true}, h.get_entry_types('inscript_short_only'))
expect.error_match('entry not found', function()
h.get_entry_types('notanentry') end)
end
function test.get_entry_short_help()
expect.eq('No help available.', h.get_entry_short_help('ls'),
'no docs for builtin fn result in default short description')
expect.eq('Documented a plugin that has commands.', h.get_entry_short_help('hascommands'))
expect.eq('Box your binders.', h.get_entry_short_help('boxbinders'),
'should get short help from command description')
expect.eq('Samename.', h.get_entry_short_help('samename'),
'should get short help from command description')
expect.eq('Documented nocommand.', h.get_entry_short_help('nocommand'))
expect.eq('Documented basic.', h.get_entry_short_help('basic'))
expect.eq('Documented subdir/scriptname.', h.get_entry_short_help('subdir/scriptname'))
expect.eq('No help available.', h.get_entry_short_help('nodocs_hascommands'))
expect.eq('cpp description.', h.get_entry_short_help('nodoc_command'),
'should get short help from command description')
expect.eq('Nodocs samename.', h.get_entry_short_help('nodocs_samename'),
'should get short help from command description')
expect.eq('No help available.', h.get_entry_short_help('nodocs_nocommand'))
expect.eq('in-file short description for inscript_docs.',
h.get_entry_short_help('inscript_docs'),
'should get short help from header comment')
expect.eq('No help available.',
h.get_entry_short_help('nodocs_script'))
expect.eq('inscript short desc.',
h.get_entry_short_help('inscript_short_only'),
'should get short help from header comment')
end
function test.get_entry_long_help()
-- long help for plugins/commands that have doc files should match the
-- contents of those files exactly
expect.eq(files['hack/docs/docs/tools/hascommands.txt'],
h.get_entry_long_help('hascommands'))
expect.eq(files['hack/docs/docs/tools/hascommands.txt'],
h.get_entry_long_help('boxbinders'))
expect.eq(files['hack/docs/docs/tools/samename.txt'],
h.get_entry_long_help('samename'))
expect.eq(files['hack/docs/docs/tools/nocommand.txt'],
h.get_entry_long_help('nocommand'))
expect.eq(files['hack/docs/docs/tools/basic.txt'],
h.get_entry_long_help('basic'))
expect.eq(files['hack/docs/docs/tools/subdir/scriptname.txt'],
h.get_entry_long_help('subdir/scriptname'))
-- plugins/commands that have no doc files get the default template
expect.eq([[ls
**
No help available.
]], h.get_entry_long_help('ls'))
expect.eq([[nodocs_hascommands
******************
No help available.
]], h.get_entry_long_help('nodocs_hascommands'))
expect.eq([[nodocs_hascommands
******************
No help available.
]], h.get_entry_long_help('nodoc_command'))
expect.eq([[Nodocs samename.
This command has the same name as its host plugin but no rst docs.]], h.get_entry_long_help('nodocs_samename'))
expect.eq([[nodocs_nocommand
****************
No help available.
]], h.get_entry_long_help('nodocs_nocommand'))
expect.eq([[nodocs_script
*************
No help available.
]], h.get_entry_long_help('nodocs_script'))
expect.eq([[inscript_short_only
*******************
No help available.
]], h.get_entry_long_help('inscript_short_only'))
-- scripts that have no doc files get the docs from the script lua source
expect.eq([[inscript_docs
=============
**Tags:** map | badtag
**Command:** "inscript_docs"
in-file inscript_docs.
Documented full help.]], h.get_entry_long_help('inscript_docs'))
end
function test.get_entry_tags()
expect.table_eq({fort=true, armok=true, units=true},
h.get_entry_tags('hascommands'))
expect.table_eq({fort=true, armok=true, units=true},
h.get_entry_tags('samename'))
expect.table_eq({fort=true, armok=true, units=true},
h.get_entry_tags('nocommand'))
expect.table_eq({map=true}, h.get_entry_tags('basic'))
expect.table_eq({map=true}, h.get_entry_tags('inscript_docs'),
'bad tags should get filtered out')
end
function test.is_tag()
-- see tags defined in the Tags.txt files entry above
expect.true_(h.is_tag('map'))
expect.true_(h.is_tag({'map', 'armok'}))
expect.false_(h.is_tag(nil))
expect.false_(h.is_tag(''))
expect.false_(h.is_tag('not_tag'))
expect.false_(h.is_tag({'map', 'not_tag', 'armok'}))
end
function test.get_tags()
expect.table_eq({'armok', 'fort', 'map', 'nomembers', 'units'},
h.get_tags())
end
function test.get_tag_data()
local tag_data = h.get_tag_data('armok')
table.sort(tag_data)
expect.table_eq({description='Tools that give you complete control over an aspect of the game or provide access to information that the game intentionally keeps hidden.',
'bindboxers', 'boxbinders', 'hascommands', 'nocommand', 'samename'},
tag_data,
'multi-line descriptions should get joined into a single line.')
tag_data = h.get_tag_data('fort')
table.sort(tag_data)
expect.table_eq({description='Tools that are useful while in fort mode.',
'bindboxers', 'boxbinders', 'hascommands', 'nocommand', 'samename'},
tag_data)
tag_data = h.get_tag_data('units')
table.sort(tag_data)
expect.table_eq({description='Tools that interact with units.',
'bindboxers', 'boxbinders', 'hascommands', 'nocommand', 'samename'},
tag_data)
tag_data = h.get_tag_data('map')
table.sort(tag_data)
expect.table_eq({description='Tools that interact with the game map.',
'basic', 'inscript_docs', 'subdir/scriptname'},
tag_data)
expect.table_eq({description='Nothing is tagged with this.'}, h.get_tag_data('nomembers'))
expect.error_match('tag not found',
function() h.get_tag_data('notatag') end)
end
function test.search_entries()
-- all entries, in alphabetical order by last path component
local expected = {'?', 'alias', 'basic', 'bindboxers', 'boxbinders',
'clear', 'cls', 'die', 'dir', 'disable', 'devel/dump-rpc', 'enable',
'fpause', 'hascommands', 'help', 'hide', 'inscript_docs',
'inscript_short_only', 'keybinding', 'kill-lua', 'load', 'ls', 'man',
'nocommand', 'nodoc_command', 'nodocs_hascommands', 'nodocs_nocommand',
'nodocs_samename', 'nodocs_script', 'plug', 'reload', 'samename',
'script', 'subdir/scriptname', 'sc-script', 'show', 'tags', 'type',
'unload'}
table.sort(expected, h.sort_by_basename)
expect.table_eq(expected, h.search_entries())
expect.table_eq(expected, h.search_entries({}))
expect.table_eq(expected, h.search_entries(nil, {}))
expect.table_eq(expected, h.search_entries({}, {}))
expected = {'inscript_docs', 'subdir/scriptname'}
expect.table_eq(expected, h.search_entries({tag='map', str='script'}))
expected = {'script', 'sc-script'}
table.sort(expected, h.sort_by_basename)
expect.table_eq(expected, h.search_entries({str='script',
entry_type='builtin'}))
expected = {'inscript_docs', 'inscript_short_only','nodocs_script',
'subdir/scriptname'}
expect.table_eq(expected, h.search_entries({str='script'},
{entry_type='builtin'}))
expected = {'bindboxers', 'boxbinders'}
expect.table_eq(expected, h.search_entries({str='box'}))
end
function test.get_commands()
local expected = {'?', 'alias', 'basic', 'bindboxers', 'boxbinders',
'clear', 'cls', 'die', 'dir', 'disable', 'devel/dump-rpc', 'enable',
'fpause', 'help', 'hide', 'inscript_docs', 'inscript_short_only',
'keybinding', 'kill-lua', 'load', 'ls', 'man', 'nodoc_command',
'nodocs_samename', 'nodocs_script', 'plug', 'reload', 'samename',
'script', 'subdir/scriptname', 'sc-script', 'show', 'tags', 'type',
'unload'}
table.sort(expected, h.sort_by_basename)
expect.table_eq(expected, h.get_commands())
end
function test.is_builtin()
expect.true_(h.is_builtin('ls'))
expect.false_(h.is_builtin('basic'))
expect.false_(h.is_builtin('notanentry'))
end
function test.help()
expect.printerr_match('No help entry found', function() h.help('blarg') end)
local mock_print = mock.func()
mock.patch(h, 'print', mock_print, function()
h.help('nocommand')
expect.eq(1, mock_print.call_count)
expect.eq(files['hack/docs/docs/tools/nocommand.txt'],
mock_print.call_args[1][1])
end)
end
function test.tags()
local mock_print = mock.func()
mock.patch(h, 'print', mock_print, function()
h.tags()
expect.eq(7, mock_print.call_count)
expect.eq('armok Tools that give you complete control over an aspect of the',
mock_print.call_args[1][1])
expect.eq(' game or provide access to information that the game',
mock_print.call_args[2][1])
expect.eq(' intentionally keeps hidden.',
mock_print.call_args[3][1])
expect.eq('fort Tools that are useful while in fort mode.',
mock_print.call_args[4][1])
expect.eq('map Tools that interact with the game map.',
mock_print.call_args[5][1])
expect.eq('nomembers Nothing is tagged with this.',
mock_print.call_args[6][1])
expect.eq('units Tools that interact with units.',
mock_print.call_args[7][1])
end)
end
function test.ls()
local mock_print = mock.func()
mock.patch(h, 'print', mock_print, function()
h.ls('doc') -- interpreted as a string
expect.eq(5, mock_print.call_count)
expect.eq('inscript_docs in-file short description for inscript_docs.',
mock_print.call_args[1][1])
expect.eq(' tags: map', mock_print.call_args[2][1])
expect.eq('nodoc_command cpp description.',
mock_print.call_args[3][1])
expect.eq('nodocs_samename Nodocs samename.',
mock_print.call_args[4][1])
expect.eq('nodocs_script No help available.',
mock_print.call_args[5][1])
end)
mock_print = mock.func()
mock.patch(h, 'print', mock_print, function()
h.ls('armok') -- interpreted as a tag
expect.eq(6, mock_print.call_count)
expect.eq('bindboxers Bind your boxers.',
mock_print.call_args[1][1])
expect.eq(' tags: armok, fort, units',
mock_print.call_args[2][1])
expect.eq('boxbinders Box your binders.',
mock_print.call_args[3][1])
expect.eq(' tags: armok, fort, units',
mock_print.call_args[4][1])
expect.eq('samename Samename.',
mock_print.call_args[5][1])
expect.eq(' tags: armok, fort, units',
mock_print.call_args[6][1])
end)
mock_print = mock.func()
mock.patch(h, 'print', mock_print, function()
h.ls('not a match')
expect.eq(1, mock_print.call_count)
expect.eq('No matches.', mock_print.call_args[1][1])
end)
end