diff --git a/docs/Lua API.rst b/docs/Lua API.rst index 527a9f3b4..ebd7a1287 100644 --- a/docs/Lua API.rst +++ b/docs/Lua API.rst @@ -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 ======== diff --git a/docs/changelog.txt b/docs/changelog.txt index d64c2ab0d..2639de182 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -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. diff --git a/library/LuaApi.cpp b/library/LuaApi.cpp index 12b0a7310..aaf780f74 100644 --- a/library/LuaApi.cpp +++ b/library/LuaApi.cpp @@ -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 &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 } diff --git a/library/include/LuaTools.h b/library/include/LuaTools.h index df89d184f..6dc5ae0bd 100644 --- a/library/include/LuaTools.h +++ b/library/include/LuaTools.h @@ -339,6 +339,8 @@ namespace DFHack {namespace Lua { } } + DFHACK_EXPORT void GetVector(lua_State *state, std::vector &pvec); + DFHACK_EXPORT int PushPosXYZ(lua_State *state, df::coord pos); DFHACK_EXPORT int PushPosXY(lua_State *state, df::coord2d pos); diff --git a/library/lua/helpdb.lua b/library/lua/helpdb.lua new file mode 100644 index 000000000..e6bd9133b --- /dev/null +++ b/library/lua/helpdb.lua @@ -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 diff --git a/test/library/helpdb.lua b/test/library/helpdb.lua new file mode 100644 index 000000000..6c2ef2e9b --- /dev/null +++ b/test/library/helpdb.lua @@ -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