refactor(gen_vimdoc): generate function doc from metadata, not from xml

Problem:

For function definitions to be included in the vimdoc (formatted) and
to be exported as mpack data (unformatted), we had two internal
representations of the same function/API metadata in duplicate;
one is FunctionDoc (which was previously a dict), and the other is
doxygen XML DOM from which vimdoc (functions sections) was generated.

Solution:

We should have a single path and unified data representation
(i.e. FunctionDoc) that contains all the metadata and information about
function APIs, from which both of mpack export and vimdoc are generated.
I.e., vimdocs are no longer generated directly from doxygen XML nodes,
but generated via:

  (XML DOM Nodes) ------------> FunctionDoc ------> mpack (unformatted)
                   Recursive     Internal     |
                   Formatting    Metadata     +---> vimdoc (formatted)

This refactoring eliminates the hacky and ugly use of `fmt_vimhelp` in
`fmt_node_as_vimhelp()` and all other helper functions! This way,
`fmt_node_as_vimhelp()` can simplified as it no longer needs to handle
generating of function docs, which needs to be done only in the topmost
level of recursion.
This commit is contained in:
Jongwook Choi 2023-12-28 17:50:05 -05:00
parent 67f5332344
commit 4e9298ecdf
2 changed files with 154 additions and 69 deletions

View File

@ -73,6 +73,9 @@ function vim.api.nvim__id_dictionary(dct) end
function vim.api.nvim__id_float(flt) end function vim.api.nvim__id_float(flt) end
--- @private --- @private
--- NB: if your UI doesn't use hlstate, this will not return hlstate first
--- time.
---
--- @param grid integer --- @param grid integer
--- @param row integer --- @param row integer
--- @param col integer --- @param col integer

View File

@ -751,7 +751,10 @@ def render_node(n, text, prefix='', indent='', width=text_width - indentation,
return text return text
def para_as_map(parent, indent='', width=text_width - indentation, fmt_vimhelp=False): def para_as_map(parent: Element,
indent: str = '',
width: int = (text_width - indentation),
):
"""Extracts a Doxygen XML <para> node to a map. """Extracts a Doxygen XML <para> node to a map.
Keys: Keys:
@ -787,7 +790,7 @@ def para_as_map(parent, indent='', width=text_width - indentation, fmt_vimhelp=F
kind = '' kind = ''
if is_inline(parent): if is_inline(parent):
# Flatten inline text from a tree of non-block nodes. # Flatten inline text from a tree of non-block nodes.
text = doc_wrap(render_node(parent, "", fmt_vimhelp=fmt_vimhelp), text = doc_wrap(render_node(parent, ""),
indent=indent, width=width) indent=indent, width=width)
else: else:
prev = None # Previous node prev = None # Previous node
@ -805,8 +808,7 @@ def para_as_map(parent, indent='', width=text_width - indentation, fmt_vimhelp=F
elif kind == 'see': elif kind == 'see':
groups['seealso'].append(child) groups['seealso'].append(child)
elif kind == 'warning': elif kind == 'warning':
text += render_node(child, text, indent=indent, text += render_node(child, text, indent=indent, width=width)
width=width, fmt_vimhelp=fmt_vimhelp)
elif kind == 'since': elif kind == 'since':
since_match = re.match(r'^(\d+)', get_text(child)) since_match = re.match(r'^(\d+)', get_text(child))
since = int(since_match.group(1)) if since_match else 0 since = int(since_match.group(1)) if since_match else 0
@ -827,8 +829,7 @@ def para_as_map(parent, indent='', width=text_width - indentation, fmt_vimhelp=F
and ' ' != text[-1]): and ' ' != text[-1]):
text += ' ' text += ' '
text += render_node(child, text, indent=indent, width=width, text += render_node(child, text, indent=indent, width=width)
fmt_vimhelp=fmt_vimhelp)
prev = child prev = child
chunks['text'] += text chunks['text'] += text
@ -839,17 +840,17 @@ def para_as_map(parent, indent='', width=text_width - indentation, fmt_vimhelp=F
update_params_map(child, ret_map=chunks['params'], width=width) update_params_map(child, ret_map=chunks['params'], width=width)
for child in groups['note']: for child in groups['note']:
chunks['note'].append(render_node( chunks['note'].append(render_node(
child, '', indent=indent, width=width, fmt_vimhelp=fmt_vimhelp).rstrip()) child, '', indent=indent, width=width).rstrip())
for child in groups['return']: for child in groups['return']:
chunks['return'].append(render_node( chunks['return'].append(render_node(
child, '', indent=indent, width=width, fmt_vimhelp=fmt_vimhelp)) child, '', indent=indent, width=width))
for child in groups['seealso']: for child in groups['seealso']:
# Example: # Example:
# <simplesect kind="see"> # <simplesect kind="see">
# <para>|autocommand|</para> # <para>|autocommand|</para>
# </simplesect> # </simplesect>
chunks['seealso'].append(render_node( chunks['seealso'].append(render_node(
child, '', indent=indent, width=width, fmt_vimhelp=fmt_vimhelp)) child, '', indent=indent, width=width))
xrefs = set() xrefs = set()
for child in groups['xrefs']: for child in groups['xrefs']:
@ -898,6 +899,9 @@ class FunctionDoc:
annotations: List[str] annotations: List[str]
"""Attributes, e.g., FUNC_API_REMOTE_ONLY. See annotation_map""" """Attributes, e.g., FUNC_API_REMOTE_ONLY. See annotation_map"""
notes: List[Docstring]
"""Notes: (@note strings)"""
signature: str signature: str
"""Function signature with *tags*.""" """Function signature with *tags*."""
@ -916,41 +920,122 @@ class FunctionDoc:
seealso: List[Docstring] seealso: List[Docstring]
"""See also: (@see strings)""" """See also: (@see strings)"""
# for fmt_node_as_vimhelp xrefs: List[Docstring]
desc_node: Element | None = None """XRefs. Currently only used to track Deprecated functions."""
brief_desc_node: Element | None = None
# for INCLUDE_C_DECL # for INCLUDE_C_DECL
c_decl: str | None = None c_decl: str | None = None
prerelease: bool = False
def fmt_node_as_vimhelp(parent: Element, width=text_width - indentation, indent='', def export_mpack(self) -> Dict[str, Any]:
fmt_vimhelp=False): """Convert a dict to be exported as mpack data."""
exported = self.__dict__.copy()
del exported['notes']
del exported['c_decl']
del exported['prerelease']
del exported['xrefs']
exported['return'] = exported.pop('return_')
return exported
def doc_concatenated(self) -> Docstring:
"""Concatenate all the paragraphs in `doc` into a single string, but
remove blank lines before 'programlisting' blocks. #25127
BEFORE (without programlisting processing):
```vimdoc
Example:
>vim
:echo nvim_get_color_by_name("Pink")
<
```
AFTER:
```vimdoc
Example: >vim
:echo nvim_get_color_by_name("Pink")
<
```
"""
def is_program_listing(paragraph: str) -> bool:
lines = paragraph.strip().split('\n')
return lines[0].startswith('>') and lines[-1] == '<'
rendered = []
for paragraph in self.doc:
if is_program_listing(paragraph):
rendered.append(' ') # Example: >vim
elif rendered:
rendered.append('\n\n')
rendered.append(paragraph)
return ''.join(rendered)
def render(self) -> Docstring:
"""Renders function documentation as Vim :help text."""
rendered_blocks: List[Docstring] = []
def fmt_param_doc(m):
"""Renders a params map as Vim :help text."""
max_name_len = max_name(m.keys()) + 4
out = ''
for name, desc in m.items():
if name == 'self':
continue
name = '{}'.format('{{{}}}'.format(name).ljust(max_name_len))
out += '{}{}\n'.format(name, desc)
return out.rstrip()
# Generate text from the gathered items.
chunks: List[Docstring] = [self.doc_concatenated()]
notes = []
if self.prerelease:
notes = [" This API is pre-release (unstable)."]
notes += self.notes
if len(notes) > 0:
chunks.append('\nNote: ~')
for s in notes:
chunks.append(' ' + s)
if self.parameters_doc:
chunks.append('\nParameters: ~')
chunks.append(fmt_param_doc(self.parameters_doc))
if self.return_:
chunks.append('\nReturn (multiple): ~' if len(self.return_) > 1
else '\nReturn: ~')
for s in self.return_:
chunks.append(' ' + s)
if self.seealso:
chunks.append('\nSee also: ~')
for s in self.seealso:
chunks.append(' ' + s)
# Note: xrefs are currently only used to remark "Deprecated: "
# for deprecated functions; visible when INCLUDE_DEPRECATED is set
for s in self.xrefs:
chunks.append('\n' + s)
rendered_blocks.append(clean_lines('\n'.join(chunks).strip()))
rendered_blocks.append('')
return clean_lines('\n'.join(rendered_blocks).strip())
def fmt_node_as_vimhelp(parent: Element, width=text_width - indentation, indent=''):
"""Renders (nested) Doxygen <para> nodes as Vim :help text. """Renders (nested) Doxygen <para> nodes as Vim :help text.
Only handles "text" nodes. Used for individual elements (see render_node())
and in extract_defgroups().
NB: Blank lines in a docstring manifest as <para> tags. NB: Blank lines in a docstring manifest as <para> tags.
""" """
rendered_blocks = [] rendered_blocks = []
def fmt_param_doc(m):
"""Renders a params map as Vim :help text."""
max_name_len = max_name(m.keys()) + 4
out = ''
for name, desc in m.items():
if name == 'self':
continue
name = '{}'.format('{{{}}}'.format(name).ljust(max_name_len))
out += '{}{}\n'.format(name, desc)
return out.rstrip()
def has_nonexcluded_params(m):
"""Returns true if any of the given params has at least
one non-excluded item."""
if fmt_param_doc(m) != '':
return True
for child in parent.childNodes: for child in parent.childNodes:
para, _ = para_as_map(child, indent, width, fmt_vimhelp) para, _ = para_as_map(child, indent, width)
# 'programlisting' blocks are Markdown code blocks. Do not include # 'programlisting' blocks are Markdown code blocks. Do not include
# these as a separate paragraph, but append to the last non-empty line # these as a separate paragraph, but append to the last non-empty line
@ -963,25 +1048,6 @@ def fmt_node_as_vimhelp(parent: Element, width=text_width - indentation, indent=
# Generate text from the gathered items. # Generate text from the gathered items.
chunks = [para['text']] chunks = [para['text']]
notes = [" This API is pre-release (unstable)."] if para['prerelease'] else []
notes += para['note']
if len(notes) > 0:
chunks.append('\nNote: ~')
for s in notes:
chunks.append(s)
if len(para['params']) > 0 and has_nonexcluded_params(para['params']):
chunks.append('\nParameters: ~')
chunks.append(fmt_param_doc(para['params']))
if len(para['return']) > 0:
chunks.append('\nReturn (multiple): ~' if len(para['return']) > 1 else '\nReturn: ~')
for s in para['return']:
chunks.append(s)
if len(para['seealso']) > 0:
chunks.append('\nSee also: ~')
for s in para['seealso']:
chunks.append(s)
for s in para['xrefs']:
chunks.append(s)
rendered_blocks.append(clean_lines('\n'.join(chunks).strip())) rendered_blocks.append(clean_lines('\n'.join(chunks).strip()))
rendered_blocks.append('') rendered_blocks.append('')
@ -989,7 +1055,8 @@ def fmt_node_as_vimhelp(parent: Element, width=text_width - indentation, indent=
return clean_lines('\n'.join(rendered_blocks).strip()) return clean_lines('\n'.join(rendered_blocks).strip())
def extract_from_xml(filename, target, width, fmt_vimhelp) -> Tuple[ def extract_from_xml(filename, target, *,
width: int, fmt_vimhelp: bool) -> Tuple[
Dict[FunctionName, FunctionDoc], Dict[FunctionName, FunctionDoc],
Dict[FunctionName, FunctionDoc], Dict[FunctionName, FunctionDoc],
]: ]:
@ -1130,18 +1197,20 @@ def extract_from_xml(filename, target, width, fmt_vimhelp) -> Tuple[
# Tracks `xrefsect` titles. As of this writing, used only for separating # Tracks `xrefsect` titles. As of this writing, used only for separating
# deprecated functions. # deprecated functions.
xrefs_all = set() xrefs_all = set()
paras: List[Dict[str, Any]] = [] paras: List[Dict[str, Any]] = [] # paras means paragraphs!
brief_desc = find_first(member, 'briefdescription') brief_desc = find_first(member, 'briefdescription')
if brief_desc: if brief_desc:
for child in brief_desc.childNodes: for child in brief_desc.childNodes:
para, xrefs = para_as_map(child) para, xrefs = para_as_map(child)
paras.append(para)
xrefs_all.update(xrefs) xrefs_all.update(xrefs)
desc = find_first(member, 'detaileddescription') desc = find_first(member, 'detaileddescription')
if desc: if desc:
paras_detail = [] # override briefdescription
for child in desc.childNodes: for child in desc.childNodes:
para, xrefs = para_as_map(child) para, xrefs = para_as_map(child)
paras.append(para) paras_detail.append(para)
xrefs_all.update(xrefs) xrefs_all.update(xrefs)
log.debug( log.debug(
textwrap.indent( textwrap.indent(
@ -1149,18 +1218,25 @@ def extract_from_xml(filename, target, width, fmt_vimhelp) -> Tuple[
desc.toprettyxml(indent=' ', newl='\n')), desc.toprettyxml(indent=' ', newl='\n')),
' ' * indentation)) ' ' * indentation))
# override briefdescription, if detaileddescription is not empty
# (note: briefdescription can contain some erroneous luadoc
# comments from preceding comments, this is a bug of lua2dox)
if any((para['text'] or para['note'] or para['params'] or
para['return'] or para['seealso']
) for para in paras_detail):
paras = paras_detail
fn = FunctionDoc( fn = FunctionDoc(
annotations=list(annotations), annotations=list(annotations),
notes=[],
signature=signature, signature=signature,
parameters=params, parameters=params,
parameters_doc=collections.OrderedDict(), parameters_doc=collections.OrderedDict(),
doc=[], doc=[],
return_=[], return_=[],
seealso=[], seealso=[],
xrefs=[],
) )
if fmt_vimhelp:
fn.desc_node = desc
fn.brief_desc_node = brief_desc
for m in paras: for m in paras:
if m.get('text', ''): if m.get('text', ''):
@ -1172,6 +1248,12 @@ def extract_from_xml(filename, target, width, fmt_vimhelp) -> Tuple[
fn.return_ += m['return'] fn.return_ += m['return']
if 'seealso' in m and len(m['seealso']) > 0: if 'seealso' in m and len(m['seealso']) > 0:
fn.seealso += m['seealso'] fn.seealso += m['seealso']
if m.get('prerelease', False):
fn.prerelease = True
if 'note' in m:
fn.notes += m['note']
if 'xrefs' in m:
fn.xrefs += m['xrefs']
if INCLUDE_C_DECL: if INCLUDE_C_DECL:
fn.c_decl = c_decl fn.c_decl = c_decl
@ -1203,17 +1285,14 @@ def fmt_doxygen_xml_as_vimhelp(filename, target) -> Tuple[Docstring, Docstring]:
deprecated_fns_txt = {} # Map of func_name:vim-help-text. deprecated_fns_txt = {} # Map of func_name:vim-help-text.
fns: Dict[FunctionName, FunctionDoc] fns: Dict[FunctionName, FunctionDoc]
fns, _ = extract_from_xml(filename, target, text_width, True) fns, _ = extract_from_xml(filename, target,
width=text_width, fmt_vimhelp=True)
for fn_name, fn in fns.items(): for fn_name, fn in fns.items():
# Generate Vim :help for parameters. # Generate Vim :help for parameters.
# Generate body. # Generate body from FunctionDoc, not XML nodes
doc = '' doc = fn.render()
if fn.desc_node:
doc = fmt_node_as_vimhelp(fn.desc_node, fmt_vimhelp=True)
if not doc and fn.brief_desc_node:
doc = fmt_node_as_vimhelp(fn.brief_desc_node)
if not doc and fn_name.startswith("nvim__"): if not doc and fn_name.startswith("nvim__"):
continue continue
if not doc: if not doc:
@ -1393,11 +1472,14 @@ def main(doxygen_config, args):
filename = get_text(find_first(compound, 'name')) filename = get_text(find_first(compound, 'name'))
if filename.endswith('.c') or filename.endswith('.lua'): if filename.endswith('.c') or filename.endswith('.lua'):
xmlfile = os.path.join(base, '{}.xml'.format(compound.getAttribute('refid'))) xmlfile = os.path.join(base, '{}.xml'.format(compound.getAttribute('refid')))
# Extract unformatted (*.mpack). # Extract unformatted (*.mpack).
fn_map, _ = extract_from_xml(xmlfile, target, 9999, False) fn_map, _ = extract_from_xml(
xmlfile, target, width=9999, fmt_vimhelp=False)
# Extract formatted (:help). # Extract formatted (:help).
functions_text, deprecated_text = fmt_doxygen_xml_as_vimhelp( functions_text, deprecated_text = fmt_doxygen_xml_as_vimhelp(
os.path.join(base, '{}.xml'.format(compound.getAttribute('refid'))), target) xmlfile, target)
if not functions_text and not deprecated_text: if not functions_text and not deprecated_text:
continue continue
@ -1461,11 +1543,11 @@ def main(doxygen_config, args):
with open(doc_file, 'ab') as fp: with open(doc_file, 'ab') as fp:
fp.write(docs.encode('utf8')) fp.write(docs.encode('utf8'))
fn_map_full = collections.OrderedDict(sorted( fn_map_full_exported = collections.OrderedDict(sorted(
(name, fn_doc.__dict__) for (name, fn_doc) in fn_map_full.items() (name, fn_doc.export_mpack()) for (name, fn_doc) in fn_map_full.items()
)) ))
with open(mpack_file, 'wb') as fp: with open(mpack_file, 'wb') as fp:
fp.write(msgpack.packb(fn_map_full, use_bin_type=True)) # type: ignore fp.write(msgpack.packb(fn_map_full_exported, use_bin_type=True)) # type: ignore
if not args.keep_tmpfiles: if not args.keep_tmpfiles:
shutil.rmtree(output_dir) shutil.rmtree(output_dir)