diff --git a/conf.py b/conf.py index dd7be9792..ff4cdc59d 100644 --- a/conf.py +++ b/conf.py @@ -14,15 +14,15 @@ serve to show the default. # pylint:disable=redefined-builtin -import contextlib import datetime -import io import os import re import shlex # pylint:disable=unused-import import sphinx import sys +sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'docs', 'sphinx_extensions')) +from dfhack.util import write_file_if_changed if os.environ.get('DFHACK_DOCS_BUILD_OFFLINE'): # block attempted image downloads, particularly for the PDF builder @@ -40,67 +40,6 @@ if os.environ.get('DFHACK_DOCS_BUILD_OFFLINE'): requests.get = request_disabled -# -- Support :dfhack-keybind:`command` ------------------------------------ -# this is a custom directive that pulls info from default keybindings - -from docutils import nodes -from docutils.parsers.rst import roles - -def get_keybinds(root, files, keybindings): - """Add keybindings in the specified files to the - given keybindings dict. - """ - for file in files: - with open(os.path.join(root, file)) as f: - lines = [l.replace('keybinding add', '').strip() for l in f.readlines() - if l.startswith('keybinding add')] - for k in lines: - first, command = k.split(' ', 1) - bind, context = (first.split('@') + [''])[:2] - if ' ' not in command: - command = command.replace('"', '') - tool = command.split(' ')[0].replace('"', '') - keybindings[tool] = keybindings.get(tool, []) + [ - (command, bind.split('-'), context)] - -def get_all_keybinds(root_dir): - """Get the implemented keybinds, and return a dict of - {tool: [(full_command, keybinding, context), ...]}. - """ - keybindings = dict() - for root, _, files in os.walk(root_dir): - get_keybinds(root, files, keybindings) - return keybindings - -KEYBINDS = get_all_keybinds('data/init') - - -# pylint:disable=unused-argument,dangerous-default-value,too-many-arguments -def dfhack_keybind_role_func(role, rawtext, text, lineno, inliner, - options={}, content=[]): - """Custom role parser for DFHack default keybinds.""" - roles.set_classes(options) - if text not in KEYBINDS: - return [], [] - newnode = nodes.paragraph() - for cmd, key, ctx in KEYBINDS[text]: - n = nodes.paragraph() - newnode += n - n += nodes.strong('Keybinding:', 'Keybinding:') - n += nodes.inline(' ', ' ') - for k in key: - n += nodes.inline(k, k, classes=['kbd']) - if cmd != text: - n += nodes.inline(' -> ', ' -> ') - n += nodes.literal(cmd, cmd, classes=['guilabel']) - if ctx: - n += nodes.inline(' in ', ' in ') - n += nodes.literal(ctx, ctx) - return [newnode], [] - - -roles.register_canonical_role('dfhack-keybind', dfhack_keybind_role_func) - # -- Autodoc for DFhack plugins and scripts ------------------------------- def doc_dir(dirname, files, prefix): @@ -145,23 +84,6 @@ def get_tags(): return tags -@contextlib.contextmanager -def write_file_if_changed(path): - with io.StringIO() as buffer: - yield buffer - new_contents = buffer.getvalue() - - try: - with open(path, 'r') as infile: - old_contents = infile.read() - except IOError: - old_contents = None - - if old_contents != new_contents: - with open(path, 'w') as outfile: - outfile.write(new_contents) - - def generate_tag_indices(): os.makedirs('docs/tags', mode=0o755, exist_ok=True) with write_file_if_changed('docs/tags/index.rst') as topidx: @@ -204,29 +126,12 @@ def write_tool_docs(): outfile.write(include) -def all_keybinds_documented(): - """Check that all keybindings are documented with the :dfhack-keybind: - directive somewhere.""" - undocumented_binds = set(KEYBINDS) - tools = set(i[0] for i in DOC_ALL_DIRS) - for t in tools: - with open(('./docs/tools/{}.rst').format(t)) as f: - tool_binds = set(re.findall(':dfhack-keybind:`(.*?)`', f.read())) - undocumented_binds -= tool_binds - if undocumented_binds: - raise ValueError('The following DFHack commands have undocumented ' - 'keybindings: {}'.format(sorted(undocumented_binds))) - - # Actually call the docs generator and run test write_tool_docs() generate_tag_indices() -#all_keybinds_documented() # comment out while we're transitioning # -- General configuration ------------------------------------------------ -sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'docs', 'sphinx_extensions')) - # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = '1.8' @@ -237,6 +142,7 @@ extensions = [ 'sphinx.ext.extlinks', 'dfhack.changelog', 'dfhack.lexer', + 'dfhack.tool_docs', ] sphinx_major_version = sphinx.version_info[0] diff --git a/docs/plugins/3dveins.rst b/docs/plugins/3dveins.rst index 680d44c6e..63f6cfd8d 100644 --- a/docs/plugins/3dveins.rst +++ b/docs/plugins/3dveins.rst @@ -1,7 +1,8 @@ 3dveins ======= -**Tags:** `tag/fort`, `tag/mod`, `tag/map` -:dfhack-keybind:`3dveins` + +.. dfhack-tool:: + :tags: fort mod map :index:`Rewrite layer veins to expand in 3D space. <3dveins; Rewrite layer veins to expand in 3D space.>` Existing, flat veins diff --git a/docs/plugins/autodump.rst b/docs/plugins/autodump.rst index 53ed7f1ba..3e8d291a0 100644 --- a/docs/plugins/autodump.rst +++ b/docs/plugins/autodump.rst @@ -1,9 +1,12 @@ autodump ======== -**Tags:** `tag/fort`, `tag/auto`, `tag/fps`, `tag/items`, `tag/stockpiles` -:dfhack-keybind:`autodump` -:dfhack-keybind:`autodump-destroy-here` -:dfhack-keybind:`autodump-destroy-item` + +.. dfhack-tool:: + :tags: fort auto fps items stockpiles + +.. dfhack-command:: autodump-destroy-here + +.. dfhack-command:: autodump-destroy-item :index:`Automatically set items in a stockpile to be dumped. ` When diff --git a/docs/plugins/autogems.rst b/docs/plugins/autogems.rst index bcff93ba6..11575aa7a 100644 --- a/docs/plugins/autogems.rst +++ b/docs/plugins/autogems.rst @@ -1,7 +1,11 @@ autogems ======== -**Tags:** `tag/fort`, `tag/auto`, `tag/jobs` -:dfhack-keybind:`autogems-reload` + +.. dfhack-tool:: autogems + :tags: fort auto jobs + :no-command: + +.. dfhack-command:: autogems-reload :index:`Automatically cut rough gems. ` This plugin periodically scans your stocks of rough gems and creates manager diff --git a/docs/plugins/automelt.rst b/docs/plugins/automelt.rst index 190bffc06..6c42cbb7e 100644 --- a/docs/plugins/automelt.rst +++ b/docs/plugins/automelt.rst @@ -1,6 +1,9 @@ automelt ======== -**Tags:** `tag/fort`, `tag/auto`, `tag/items`, `tag/stockpiles` + +.. dfhack-tool:: + :tags: fort auto items stockpiles + :no-command: :index:`Quickly designate items to be melted. ` When `enabled `, this diff --git a/docs/sphinx_extensions/dfhack/changelog.py b/docs/sphinx_extensions/dfhack/changelog.py index 2f27590fd..0cd732988 100644 --- a/docs/sphinx_extensions/dfhack/changelog.py +++ b/docs/sphinx_extensions/dfhack/changelog.py @@ -6,7 +6,7 @@ import sys from sphinx.errors import ExtensionError, SphinxError, SphinxWarning -from dfhack.util import DFHACK_ROOT, DOCS_ROOT +from dfhack.util import DFHACK_ROOT, DOCS_ROOT, write_file_if_changed CHANGELOG_PATHS = ( 'docs/changelog.txt', @@ -172,7 +172,7 @@ def consolidate_changelog(all_entries): def print_changelog(versions, all_entries, path, replace=True, prefix=''): # all_entries: version -> section -> entry - with open(path, 'w') as f: + with write_file_if_changed(path) as f: def write(line): if replace: line = replace_text(line, REPLACEMENTS) diff --git a/docs/sphinx_extensions/dfhack/tool_docs.py b/docs/sphinx_extensions/dfhack/tool_docs.py new file mode 100644 index 000000000..ff4bfc9b1 --- /dev/null +++ b/docs/sphinx_extensions/dfhack/tool_docs.py @@ -0,0 +1,175 @@ +# useful references: +# https://www.sphinx-doc.org/en/master/extdev/appapi.html +# https://www.sphinx-doc.org/en/master/development/tutorials/recipe.html +# https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#rst-directives + +import logging +import os +from typing import List + +import docutils.nodes as nodes +import docutils.parsers.rst.directives as rst_directives +import sphinx +import sphinx.addnodes as addnodes +import sphinx.directives + +import dfhack.util + + +logger = sphinx.util.logging.getLogger(__name__) + +_KEYBINDS = {} +_KEYBINDS_RENDERED = set() # commands whose keybindings have been rendered + +def scan_keybinds(root, files, keybindings): + """Add keybindings in the specified files to the + given keybindings dict. + """ + for file in files: + with open(os.path.join(root, file)) as f: + lines = [l.replace('keybinding add', '').strip() for l in f.readlines() + if l.startswith('keybinding add')] + for k in lines: + first, command = k.split(' ', 1) + bind, context = (first.split('@') + [''])[:2] + if ' ' not in command: + command = command.replace('"', '') + tool = command.split(' ')[0].replace('"', '') + keybindings[tool] = keybindings.get(tool, []) + [ + (command, bind.split('-'), context)] + +def scan_all_keybinds(root_dir): + """Get the implemented keybinds, and return a dict of + {tool: [(full_command, keybinding, context), ...]}. + """ + keybindings = dict() + for root, _, files in os.walk(root_dir): + scan_keybinds(root, files, keybindings) + return keybindings + + +def render_dfhack_keybind(command) -> List[nodes.paragraph]: + _KEYBINDS_RENDERED.add(command) + out = [] + if command not in _KEYBINDS: + return out + for keycmd, key, ctx in _KEYBINDS[command]: + n = nodes.paragraph() + n += nodes.strong('Keybinding:', 'Keybinding:') + n += nodes.inline(' ', ' ') + for k in key: + n += nodes.inline(k, k, classes=['kbd']) + if keycmd != command: + n += nodes.inline(' -> ', ' -> ') + n += nodes.literal(keycmd, keycmd, classes=['guilabel']) + if ctx: + n += nodes.inline(' in ', ' in ') + n += nodes.literal(ctx, ctx) + out.append(n) + return out + + +def check_missing_keybinds(): + # FIXME: _KEYBINDS_RENDERED is empty in the parent process under parallel builds + # consider moving to a sphinx Domain to solve this properly + for missing_command in sorted(set(_KEYBINDS.keys()) - _KEYBINDS_RENDERED): + logger.warning('Undocumented keybindings for command: %s', missing_command) + + +# pylint:disable=unused-argument,dangerous-default-value,too-many-arguments +def dfhack_keybind_role(role, rawtext, text, lineno, inliner, + options={}, content=[]): + """Custom role parser for DFHack default keybinds.""" + return render_dfhack_keybind(text), [] + + +class DFHackToolDirectiveBase(sphinx.directives.ObjectDescription): + has_content = False + required_arguments = 0 + optional_arguments = 1 + + def get_name_or_docname(self): + if self.arguments: + return self.arguments[0] + else: + return self.env.docname.split('/')[-1] + + @staticmethod + def make_labeled_paragraph(label, content, label_class=nodes.strong, content_class=nodes.inline) -> nodes.paragraph: + return nodes.paragraph('', '', *[ + label_class('', '{}:'.format(label)), + nodes.inline(text=' '), + content_class('', content), + ]) + + @staticmethod + def wrap_box(*children: List[nodes.Node]) -> nodes.Admonition: + return nodes.topic('', *children, classes=['dfhack-tool-summary']) + + def render_content(self) -> List[nodes.Node]: + raise NotImplementedError + + def run(self): + return [self.wrap_box(*self.render_content())] + + +class DFHackToolDirective(DFHackToolDirectiveBase): + option_spec = { + 'tags': dfhack.util.directive_arg_str_list, + 'no-command': rst_directives.flag, + } + + def render_content(self) -> List[nodes.Node]: + tag_nodes = [nodes.strong(text='Tags:'), nodes.inline(text=' ')] + for tag in self.options.get('tags', []): + tag_nodes += [ + addnodes.pending_xref(tag, nodes.inline(text=tag), **{ + 'reftype': 'ref', + 'refdomain': 'std', + 'reftarget': 'tag/' + tag, + 'refexplicit': False, + 'refwarn': True, + }), + nodes.inline(text=' | '), + ] + tag_nodes.pop() + + return [ + nodes.paragraph('', '', *tag_nodes), + ] + + def run(self): + out = DFHackToolDirectiveBase.run(self) + if 'no-command' not in self.options: + out += [self.wrap_box(*DFHackCommandDirective.render_content(self))] + return out + + +class DFHackCommandDirective(DFHackToolDirectiveBase): + def render_content(self) -> List[nodes.Node]: + command = self.get_name_or_docname() + return [ + self.make_labeled_paragraph('Command', command, content_class=nodes.literal), + *render_dfhack_keybind(command), + ] + + +def register(app): + app.add_directive('dfhack-tool', DFHackToolDirective) + app.add_directive('dfhack-command', DFHackCommandDirective) + app.add_role('dfhack-keybind', dfhack_keybind_role) + + _KEYBINDS.update(scan_all_keybinds(os.path.join(dfhack.util.DFHACK_ROOT, 'data', 'init'))) + + +def setup(app): + app.connect('builder-inited', register) + + # TODO: re-enable once detection is corrected + # app.connect('build-finished', lambda *_: check_missing_keybinds()) + + return { + 'version': '0.1', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/docs/sphinx_extensions/dfhack/util.py b/docs/sphinx_extensions/dfhack/util.py index 71a432da4..91f0accbe 100644 --- a/docs/sphinx_extensions/dfhack/util.py +++ b/docs/sphinx_extensions/dfhack/util.py @@ -1,3 +1,5 @@ +import contextlib +import io import os DFHACK_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) @@ -5,3 +7,34 @@ DOCS_ROOT = os.path.join(DFHACK_ROOT, 'docs') if not os.path.isdir(DOCS_ROOT): raise ReferenceError('docs root not found: %s' % DOCS_ROOT) + + +@contextlib.contextmanager +def write_file_if_changed(path): + with io.StringIO() as buffer: + yield buffer + new_contents = buffer.getvalue() + + try: + with open(path, 'r') as infile: + old_contents = infile.read() + except IOError: + old_contents = None + + if old_contents != new_contents: + with open(path, 'w') as outfile: + outfile.write(new_contents) + + +# directive argument helpers (supplementing docutils.parsers.rst.directives) +def directive_arg_str_list(argument): + """ + Converts a space- or comma-separated list of values into a Python list + of strings. + (Directive option conversion function.) + """ + if ',' in argument: + entries = argument.split(',') + else: + entries = argument.split() + return [entry.strip() for entry in entries] diff --git a/docs/styles/dfhack.css b/docs/styles/dfhack.css index 9b6e523ef..4102e6412 100644 --- a/docs/styles/dfhack.css +++ b/docs/styles/dfhack.css @@ -60,3 +60,14 @@ div.body { span.pre { overflow-wrap: break-word; } + +div.dfhack-tool-summary { + margin: 10px 0; + padding: 10px 15px; +} + +div.dfhack-tool-summary p { + margin-top: 0; + margin-bottom: 0.5em; + line-height: 1em; +}