diff --git a/docs/Tags.rst b/docs/Tags.rst new file mode 100644 index 000000000..effc0ad23 --- /dev/null +++ b/docs/Tags.rst @@ -0,0 +1,14 @@ +Tags +==== + +This is the list of tags and their descriptions. + +- adventure: tools relevant to adventure mode +- fort: tools relevant to fort mode +- legends: tools relevant to legends mode +- enable: tools that are able to be enabled/disabled for some persistent effect +- items: tools that create or modify in-game items +- units: tools that create or modify units +- jobs: tools that create or modify jobs +- labors: tools that deal with labor assignment +- auto: tools that automatically manage some aspect of your fortress diff --git a/index.rst b/index.rst index 63ab02a86..3e761bbc6 100644 --- a/index.rst +++ b/index.rst @@ -32,6 +32,7 @@ User Manual /docs/Core /docs/Plugins /docs/Scripts + /docs/Tags /docs/Tools /docs/guides/index /docs/index-about diff --git a/library/LuaApi.cpp b/library/LuaApi.cpp index 101b645fd..fb557329d 100644 --- a/library/LuaApi.cpp +++ b/library/LuaApi.cpp @@ -3023,6 +3023,79 @@ 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 int internal_getCommandHelp(lua_State *L) +{ + auto plugins = Core::getInstance().getPluginManager(); + + const char *name = luaL_checkstring(L, 1); + + auto plugin = plugins->getPluginByCommand(name); + if (!plugin) + { + lua_pushnil(L); + return 1; + } + + size_t num_commands = plugin->size(); + for (size_t i = 0; i < num_commands; ++i) + { + if ((*plugin)[i].name == name) + { + const auto &pc = (*plugin)[i]; + std::string help = pc.description; + if (help.size() && help[help.size()-1] != '.') + { + help += "."; + } + help += "\n" + pc.usage; + lua_pushstring(L, help.c_str()); + return 1; + } + } + + // not found (somehow) + lua_pushnil(L); + return 1; +} + static int internal_threadid(lua_State *L) { std::stringstream ss; @@ -3094,6 +3167,9 @@ 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 }, { "threadid", internal_threadid }, { "md5File", internal_md5file }, { NULL, NULL } diff --git a/library/lua/helpdb.lua b/library/lua/helpdb.lua new file mode 100644 index 000000000..cc8665aa1 --- /dev/null +++ b/library/lua/helpdb.lua @@ -0,0 +1,410 @@ +-- The help text database. +-- +-- Command help is read from the the following sources: +-- 1. rendered text in hack/docs/docs/ +-- 2. (for scripts) the script sources if no pre-rendered text exists or if the +-- script file has a modification time that is more recent than the +-- pre-rendered text +-- 3. (for plugins) the string passed to the PluginCommand initializer if no +-- pre-rendered text exists +-- +-- For plugins that don't register any commands, the plugin name serves as the +-- command so documentation on what happens when you enable the plugin can be +-- found. + +local _ENV = mkmodule('helpdb') + +local RENDERED_PATH = 'hack/docs/docs/tools/' +local TAG_DEFINITIONS = 'hack/docs/docs/Tags.txt' + +local SCRIPT_DOC_BEGIN = '[====[' +local SCRIPT_DOC_END = ']====]' + +local SOURCES = { + STUB='stub', + RENDERED='rendered', + PLUGIN='plugin', + SCRIPT='script', +} + +-- command name -> {short_help, long_help, tags, source, source_timestamp} +-- also includes a script_source_path element if the source is a script +-- and a unrunnable boolean if the source is a plugin that does not provide any +-- commands to invoke directly. +db = db or {} + +-- tag name -> list of command names +tag_index = tag_index or {} + +local function get_rendered_path(command) + return RENDERED_PATH .. command .. '.txt' +end + +local function has_rendered_help(command) + return dfhack.filesystem.mtime(get_rendered_path(command)) ~= -1 +end + +local DEFAULT_HELP_TEMPLATE = [[ +%s +%s + +Tags: None + +No help available. +]] + +local function make_default_entry(command, source) + local default_long_help = DEFAULT_HELP_TEMPLATE:format( + command, ('*'):rep(#command)) + return {short_help='No help available.', long_help=default_long_help, + tags={}, source=source, source_timestamp=0} +end + +-- updates the short_text, the long_text, and the tags in the given entry +local function update_entry(entry, iterator, opts) + opts = opts or {} + local lines = {} + local begin_marker_found,header_found = not opts.begin_marker,opts.no_header + local tags_found, short_help_found, in_short_help = false, false, false + for line in iterator do + if not short_help_found and opts.first_line_is_short_help then + local _,_,text = line:trim():find('^%-%-%s*(.*)') + if text[#text] ~= '.' then + text = text .. '.' + end + entry.short_help = text + short_help_found = true + goto continue + 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 not tags_found and line:find('^Tags: [%w, ]+$') then + -- tags must appear before the help text begins + local _,_,tags = line:trim():find('Tags: (.*)') + entry.tags = tags:split('[ ,]+') + table.sort(entry.tags) + tags_found = true + elseif not short_help_found and not line:find('^Keybinding:') 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.long_help = table.concat(lines, '\n') +end + +local function make_rendered_entry(old_entry, command) + local rendered_path = get_rendered_path(command) + local source_timestamp = dfhack.filesystem.mtime(rendered_path) + if old_entry and old_entry.source == SOURCES.RENDERED and + old_entry.source_timestamp >= source_timestamp then + -- we already have the latest info + return old_entry + end + local entry = make_default_entry(command, SOURCES.RENDERED) + update_entry(entry, io.lines(rendered_path)) + entry.source_timestamp = source_timestamp + return entry +end + +local function make_plugin_entry(old_entry, command) + if old_entry and old_entry.source == 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(command, SOURCES.PLUGIN) + local long_help = dfhack.internal.getCommandHelp(command) + if long_help and #long_help:trim() > 0 then + update_entry(entry, long_help:trim():gmatch('[^\n]*'), {no_header=true}) + end + return entry +end + +local function make_script_entry(old_entry, command, script_source_path) + local source_timestamp = dfhack.filesystem.mtime(script_source_path) + if old_entry and old_entry.source == SOURCES.SCRIPT and + old_entry.script_source_path == script_source_path and + old_entry.source_timestamp >= source_timestamp then + -- we already have the latest info + return old_entry + end + local entry = make_default_entry(command, SOURCES.SCRIPT) + update_entry(entry, io.lines(script_source_path), + {begin_marker=SCRIPT_DOC_BEGIN, end_marker=SCRIPT_DOC_END, + first_line_is_short_help=true}) + entry.source_timestamp = source_timestamp + return entry +end + +local function update_db(old_db, db, source, command, flags) + if db[command] then + -- already in db (e.g. from a higher-priority script dir); skip + return + end + local entry, old_entry = nil, old_db[command] + if source == SOURCES.RENDERED then + entry = make_rendered_entry(old_entry, command) + elseif source == SOURCES.PLUGIN then + entry = make_plugin_entry(old_entry, command) + elseif source == SOURCES.SCRIPT then + entry = make_script_entry(old_entry, command, flags.script_source) + elseif source == SOURCES.STUB then + entry = make_default_entry(command, SOURCES.STUB) + else + error('unhandled help source: ' .. source) + end + + entry.unrunnable = (flags or {}).unrunnable + + db[command] = entry + for _,tag in ipairs(entry.tags) do + -- unknown tags are ignored + if tag_index[tag] then + table.insert(tag_index[tag], command) + end + end +end + +local function scan_plugins(old_db, db) + local plugin_names = dfhack.internal.listPlugins() + for _,plugin in ipairs(plugin_names) do + local commands = dfhack.internal.listCommands(plugin) + if #commands == 0 then + -- use plugin name as the command so we have something to anchor the + -- documentation to + update_db(old_db, db, + has_rendered_help(plugin) and + SOURCES.RENDERED or SOURCES.STUB, + plugin, {unrunnable=true}) + goto continue + end + for _,command in ipairs(commands) do + update_db(old_db, db, + has_rendered_help(command) and + SOURCES.RENDERED or SOURCES.PLUGIN, + command) + end + ::continue:: + end +end + +local function scan_scripts(old_db, db) + 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') or + f.path:startswith('test/') or + f.path:startswith('internal/') then + goto continue + end + local script_source = script_path .. '/' .. f.path + local script_is_newer = dfhack.filesystem.mtime(script_source) > + dfhack.filesystem.mtime(get_rendered_path(f.path)) + update_db(old_db, db, + script_is_newer and SOURCES.SCRIPT or SOURCES.RENDERED, + f.path:sub(1, #f.path - 4), {script_source=script_source}) + ::continue:: + end + ::skip_path:: + end +end + +local function initialize_tags() + local tag, desc, in_desc = nil, nil, false + for line in io.lines(TAG_DEFINITIONS) 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 + +-- 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 second. +last_refresh_ms = last_refresh_ms or 0 +local function ensure_db() + local now_ms = dfhack.getTickCount() + if now_ms - last_refresh_ms < 1000 then return end + last_refresh_ms = now_ms + + local old_db = db + db, tag_index = {}, {} + + initialize_tags() + scan_plugins(old_db, db) + scan_scripts(old_db, db) +end + +local function get_db_property(command, property) + ensure_db() + if not db[command] then + error(('command not found: "%s"'):format(command)) + end + return db[command][property] +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 list of tags associated with the entry +function get_entry_tags(entry) + return get_db_property(entry, 'tags') +end + +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 +local 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 add_if_matched(commands, command, strs, runnable) + if runnable and db[command].unrunnable then + return + end + if strs then + local matched = false + for _,str in ipairs(strs) do + if command:find(str, 1, true) then + matched = true + break + end + end + if not matched then + return + end + end + table.insert(commands, command) +end + +-- returns a list of identifiers, alphabetized by their last path component +-- (e.g. gui/autobutcher will immediately follow autobutcher). +-- the optional filter element is a map with the following elements: +-- str - if a string, filters by the given substring. if a table of strings, +-- includes commands that match any of the given substrings. +-- tag - if a string, filters by the given tag name. if a table of strings, +-- includes commands that match any of the given tags. +-- runnable - if true, filters out plugin names that do no correspond to +-- runnable commands. +function list_entries(filter) + ensure_db() + filter = filter or {} + local commands = {} + local strs = filter.str + if filter.str then + if type(strs) == 'string' then strs = {strs} end + end + local runnable = filter.runnable + if not filter.tag then + for command in pairs(db) do + add_if_matched(commands, command, strs, runnable) + end + else + local command_set = {} + local tags = filter.tag + if type(tags) == 'string' then tags = {tags} end + for _,tag in ipairs(tags) do + if not tag_index[tag] then + error('invalid tag: ' .. tag) + end + for _,command in ipairs(tag_index[tag]) do + command_set[command] = true + end + end + for command in pairs(command_set) do + add_if_matched(commands, command, strs, runnable) + end + end + table.sort(commands, sort_by_basename) + return commands +end + +-- returns the defined tags in alphabetical order +function list_tags() + ensure_db() + local tags = {} + for tag in pairs(tag_index) do + table.insert(tags, tag) + end + table.sort(tags) + return tags +end + +-- returns the description associated with the given tag +function get_tag_description(tag) + ensure_db() + if not tag_index[tag] then + error('invalid tag: ' .. tag) + end + return tag_index[tag].description +end + +return _ENV