gen_vimdoc.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169
  1. #!/usr/bin/env python3
  2. """Generates Nvim :help docs from C/Lua docstrings, using Doxygen.
  3. Also generates *.mpack files. To inspect the *.mpack structure:
  4. :new | put=v:lua.vim.inspect(msgpackparse(readfile('runtime/doc/api.mpack')))
  5. Flow:
  6. main
  7. extract_from_xml
  8. fmt_node_as_vimhelp \
  9. para_as_map } recursive
  10. update_params_map /
  11. render_node
  12. This would be easier using lxml and XSLT, but:
  13. 1. This should avoid needing Python dependencies, especially ones that are
  14. C modules that have library dependencies (lxml requires libxml and
  15. libxslt).
  16. 2. I wouldn't know how to deal with nested indentation in <para> tags using
  17. XSLT.
  18. Each function :help block is formatted as follows:
  19. - Max width of 78 columns (`text_width`).
  20. - Indent with spaces (not tabs).
  21. - Indent of 16 columns for body text.
  22. - Function signature and helptag (right-aligned) on the same line.
  23. - Signature and helptag must have a minimum of 8 spaces between them.
  24. - If the signature is too long, it is placed on the line after the helptag.
  25. Signature wraps at `text_width - 8` characters with subsequent
  26. lines indented to the open parenthesis.
  27. - Subsection bodies are indented an additional 4 spaces.
  28. - Body consists of function description, parameters, return description, and
  29. C declaration (`INCLUDE_C_DECL`).
  30. - Parameters are omitted for the `void` and `Error *` types, or if the
  31. parameter is marked as [out].
  32. - Each function documentation is separated by a single line.
  33. """
  34. import argparse
  35. import os
  36. import re
  37. import sys
  38. import shutil
  39. import textwrap
  40. import subprocess
  41. import collections
  42. import msgpack
  43. import logging
  44. from xml.dom import minidom
  45. MIN_PYTHON_VERSION = (3, 5)
  46. if sys.version_info < MIN_PYTHON_VERSION:
  47. print("requires Python {}.{}+".format(*MIN_PYTHON_VERSION))
  48. sys.exit(1)
  49. # DEBUG = ('DEBUG' in os.environ)
  50. INCLUDE_C_DECL = ('INCLUDE_C_DECL' in os.environ)
  51. INCLUDE_DEPRECATED = ('INCLUDE_DEPRECATED' in os.environ)
  52. log = logging.getLogger(__name__)
  53. LOG_LEVELS = {
  54. logging.getLevelName(level): level for level in [
  55. logging.DEBUG, logging.INFO, logging.ERROR
  56. ]
  57. }
  58. fmt_vimhelp = False # HACK
  59. text_width = 78
  60. script_path = os.path.abspath(__file__)
  61. base_dir = os.path.dirname(os.path.dirname(script_path))
  62. out_dir = os.path.join(base_dir, 'tmp-{target}-doc')
  63. filter_cmd = '%s %s' % (sys.executable, script_path)
  64. seen_funcs = set()
  65. msgs = [] # Messages to show on exit.
  66. lua2dox_filter = os.path.join(base_dir, 'scripts', 'lua2dox_filter')
  67. CONFIG = {
  68. 'api': {
  69. 'mode': 'c',
  70. 'filename': 'api.txt',
  71. # String used to find the start of the generated part of the doc.
  72. 'section_start_token': '*api-global*',
  73. # Section ordering.
  74. 'section_order': [
  75. 'vim.c',
  76. 'buffer.c',
  77. 'window.c',
  78. 'tabpage.c',
  79. 'ui.c',
  80. ],
  81. # List of files/directories for doxygen to read, separated by blanks
  82. 'files': os.path.join(base_dir, 'src/nvim/api'),
  83. # file patterns used by doxygen
  84. 'file_patterns': '*.h *.c',
  85. # Only function with this prefix are considered
  86. 'fn_name_prefix': 'nvim_',
  87. # Section name overrides.
  88. 'section_name': {
  89. 'vim.c': 'Global',
  90. },
  91. # For generated section names.
  92. 'section_fmt': lambda name: f'{name} Functions',
  93. # Section helptag.
  94. 'helptag_fmt': lambda name: f'*api-{name.lower()}*',
  95. # Per-function helptag.
  96. 'fn_helptag_fmt': lambda fstem, name: f'*{name}()*',
  97. # Module name overrides (for Lua).
  98. 'module_override': {},
  99. # Append the docs for these modules, do not start a new section.
  100. 'append_only': [],
  101. },
  102. 'lua': {
  103. 'mode': 'lua',
  104. 'filename': 'lua.txt',
  105. 'section_start_token': '*lua-vim*',
  106. 'section_order': [
  107. 'vim.lua',
  108. 'shared.lua',
  109. 'uri.lua',
  110. ],
  111. 'files': ' '.join([
  112. os.path.join(base_dir, 'src/nvim/lua/vim.lua'),
  113. os.path.join(base_dir, 'runtime/lua/vim/shared.lua'),
  114. os.path.join(base_dir, 'runtime/lua/vim/uri.lua'),
  115. ]),
  116. 'file_patterns': '*.lua',
  117. 'fn_name_prefix': '',
  118. 'section_name': {
  119. 'lsp.lua': 'core',
  120. },
  121. 'section_fmt': lambda name: f'Lua module: {name.lower()}',
  122. 'helptag_fmt': lambda name: f'*lua-{name.lower()}*',
  123. 'fn_helptag_fmt': lambda fstem, name: f'*{fstem}.{name}()*',
  124. 'module_override': {
  125. # `shared` functions are exposed on the `vim` module.
  126. 'shared': 'vim',
  127. 'uri': 'vim',
  128. },
  129. 'append_only': [
  130. 'shared.lua',
  131. ],
  132. },
  133. 'lsp': {
  134. 'mode': 'lua',
  135. 'filename': 'lsp.txt',
  136. 'section_start_token': '*lsp-core*',
  137. 'section_order': [
  138. 'lsp.lua',
  139. 'buf.lua',
  140. 'diagnostic.lua',
  141. 'codelens.lua',
  142. 'handlers.lua',
  143. 'util.lua',
  144. 'log.lua',
  145. 'rpc.lua',
  146. 'protocol.lua',
  147. ],
  148. 'files': ' '.join([
  149. os.path.join(base_dir, 'runtime/lua/vim/lsp'),
  150. os.path.join(base_dir, 'runtime/lua/vim/lsp.lua'),
  151. ]),
  152. 'file_patterns': '*.lua',
  153. 'fn_name_prefix': '',
  154. 'section_name': {'lsp.lua': 'lsp'},
  155. 'section_fmt': lambda name: (
  156. 'Lua module: vim.lsp'
  157. if name.lower() == 'lsp'
  158. else f'Lua module: vim.lsp.{name.lower()}'),
  159. 'helptag_fmt': lambda name: (
  160. '*lsp-core*'
  161. if name.lower() == 'lsp'
  162. else f'*lsp-{name.lower()}*'),
  163. 'fn_helptag_fmt': lambda fstem, name: (
  164. f'*vim.lsp.{name}()*'
  165. if fstem == 'lsp' and name != 'client'
  166. else (
  167. '*vim.lsp.client*'
  168. # HACK. TODO(justinmk): class/structure support in lua2dox
  169. if 'lsp.client' == f'{fstem}.{name}'
  170. else f'*vim.lsp.{fstem}.{name}()*')),
  171. 'module_override': {},
  172. 'append_only': [],
  173. },
  174. 'treesitter': {
  175. 'mode': 'lua',
  176. 'filename': 'treesitter.txt',
  177. 'section_start_token': '*lua-treesitter-core*',
  178. 'section_order': [
  179. 'treesitter.lua',
  180. 'language.lua',
  181. 'query.lua',
  182. 'highlighter.lua',
  183. 'languagetree.lua',
  184. 'health.lua',
  185. ],
  186. 'files': ' '.join([
  187. os.path.join(base_dir, 'runtime/lua/vim/treesitter.lua'),
  188. os.path.join(base_dir, 'runtime/lua/vim/treesitter/'),
  189. ]),
  190. 'file_patterns': '*.lua',
  191. 'fn_name_prefix': '',
  192. 'section_name': {},
  193. 'section_fmt': lambda name: (
  194. 'Lua module: vim.treesitter'
  195. if name.lower() == 'treesitter'
  196. else f'Lua module: vim.treesitter.{name.lower()}'),
  197. 'helptag_fmt': lambda name: (
  198. '*lua-treesitter-core*'
  199. if name.lower() == 'treesitter'
  200. else f'*treesitter-{name.lower()}*'),
  201. 'fn_helptag_fmt': lambda fstem, name: (
  202. f'*{name}()*'
  203. if name != 'new'
  204. else f'*{fstem}.{name}()*'),
  205. # 'fn_helptag_fmt': lambda fstem, name: (
  206. # f'*vim.treesitter.{name}()*'
  207. # if fstem == 'treesitter'
  208. # else (
  209. # '*vim.lsp.client*'
  210. # # HACK. TODO(justinmk): class/structure support in lua2dox
  211. # if 'lsp.client' == f'{fstem}.{name}'
  212. # else f'*vim.lsp.{fstem}.{name}()*')),
  213. 'module_override': {},
  214. 'append_only': [],
  215. }
  216. }
  217. param_exclude = (
  218. 'channel_id',
  219. )
  220. # Annotations are displayed as line items after API function descriptions.
  221. annotation_map = {
  222. 'FUNC_API_FAST': '{fast}',
  223. 'FUNC_API_CHECK_TEXTLOCK': 'not allowed when |textlock| is active',
  224. }
  225. # Tracks `xrefsect` titles. As of this writing, used only for separating
  226. # deprecated functions.
  227. xrefs = set()
  228. # Raises an error with details about `o`, if `cond` is in object `o`,
  229. # or if `cond()` is callable and returns True.
  230. def debug_this(o, cond=True):
  231. name = ''
  232. if not isinstance(o, str):
  233. try:
  234. name = o.nodeName
  235. o = o.toprettyxml(indent=' ', newl='\n')
  236. except Exception:
  237. pass
  238. if ((callable(cond) and cond())
  239. or (not callable(cond) and cond)
  240. or (not callable(cond) and cond in o)):
  241. raise RuntimeError('xxx: {}\n{}'.format(name, o))
  242. # Appends a message to a list which will be printed on exit.
  243. def msg(s):
  244. msgs.append(s)
  245. # Print all collected messages.
  246. def msg_report():
  247. for m in msgs:
  248. print(f' {m}')
  249. # Print collected messages, then throw an exception.
  250. def fail(s):
  251. msg_report()
  252. raise RuntimeError(s)
  253. def find_first(parent, name):
  254. """Finds the first matching node within parent."""
  255. sub = parent.getElementsByTagName(name)
  256. if not sub:
  257. return None
  258. return sub[0]
  259. def iter_children(parent, name):
  260. """Yields matching child nodes within parent."""
  261. for child in parent.childNodes:
  262. if child.nodeType == child.ELEMENT_NODE and child.nodeName == name:
  263. yield child
  264. def get_child(parent, name):
  265. """Gets the first matching child node."""
  266. for child in iter_children(parent, name):
  267. return child
  268. return None
  269. def self_or_child(n):
  270. """Gets the first child node, or self."""
  271. if len(n.childNodes) == 0:
  272. return n
  273. return n.childNodes[0]
  274. def clean_text(text):
  275. """Cleans text.
  276. Only cleans superfluous whitespace at the moment.
  277. """
  278. return ' '.join(text.split()).strip()
  279. def clean_lines(text):
  280. """Removes superfluous lines.
  281. The beginning and end of the string is trimmed. Empty lines are collapsed.
  282. """
  283. return re.sub(r'\A\n\s*\n*|\n\s*\n*\Z', '', re.sub(r'(\n\s*\n+)+', '\n\n', text))
  284. def is_blank(text):
  285. return '' == clean_lines(text)
  286. def get_text(n, preformatted=False):
  287. """Recursively concatenates all text in a node tree."""
  288. text = ''
  289. if n.nodeType == n.TEXT_NODE:
  290. return n.data
  291. if n.nodeName == 'computeroutput':
  292. for node in n.childNodes:
  293. text += get_text(node)
  294. return '`{}` '.format(text)
  295. for node in n.childNodes:
  296. if node.nodeType == node.TEXT_NODE:
  297. text += node.data if preformatted else clean_text(node.data)
  298. elif node.nodeType == node.ELEMENT_NODE:
  299. text += ' ' + get_text(node, preformatted)
  300. return text
  301. # Gets the length of the last line in `text`, excluding newline ("\n") char.
  302. def len_lastline(text):
  303. lastnl = text.rfind('\n')
  304. if -1 == lastnl:
  305. return len(text)
  306. if '\n' == text[-1]:
  307. return lastnl - (1 + text.rfind('\n', 0, lastnl))
  308. return len(text) - (1 + lastnl)
  309. def len_lastline_withoutindent(text, indent):
  310. n = len_lastline(text)
  311. return (n - len(indent)) if n > len(indent) else 0
  312. # Returns True if node `n` contains only inline (not block-level) elements.
  313. def is_inline(n):
  314. # if len(n.childNodes) == 0:
  315. # return n.nodeType == n.TEXT_NODE or n.nodeName == 'computeroutput'
  316. for c in n.childNodes:
  317. if c.nodeType != c.TEXT_NODE and c.nodeName != 'computeroutput':
  318. return False
  319. if not is_inline(c):
  320. return False
  321. return True
  322. def doc_wrap(text, prefix='', width=70, func=False, indent=None):
  323. """Wraps text to `width`.
  324. First line is prefixed with `prefix`, subsequent lines are aligned.
  325. If `func` is True, only wrap at commas.
  326. """
  327. if not width:
  328. # return prefix + text
  329. return text
  330. # Whitespace used to indent all lines except the first line.
  331. indent = ' ' * len(prefix) if indent is None else indent
  332. indent_only = (prefix == '' and indent is not None)
  333. if func:
  334. lines = [prefix]
  335. for part in text.split(', '):
  336. if part[-1] not in ');':
  337. part += ', '
  338. if len(lines[-1]) + len(part) > width:
  339. lines.append(indent)
  340. lines[-1] += part
  341. return '\n'.join(x.rstrip() for x in lines).rstrip()
  342. # XXX: Dummy prefix to force TextWrapper() to wrap the first line.
  343. if indent_only:
  344. prefix = indent
  345. tw = textwrap.TextWrapper(break_long_words=False,
  346. break_on_hyphens=False,
  347. width=width,
  348. initial_indent=prefix,
  349. subsequent_indent=indent)
  350. result = '\n'.join(tw.wrap(text.strip()))
  351. # XXX: Remove the dummy prefix.
  352. if indent_only:
  353. result = result[len(indent):]
  354. return result
  355. def max_name(names):
  356. if len(names) == 0:
  357. return 0
  358. return max(len(name) for name in names)
  359. def update_params_map(parent, ret_map, width=62):
  360. """Updates `ret_map` with name:desc key-value pairs extracted
  361. from Doxygen XML node `parent`.
  362. """
  363. params = collections.OrderedDict()
  364. for node in parent.childNodes:
  365. if node.nodeType == node.TEXT_NODE:
  366. continue
  367. name_node = find_first(node, 'parametername')
  368. if name_node.getAttribute('direction') == 'out':
  369. continue
  370. name = get_text(name_node)
  371. if name in param_exclude:
  372. continue
  373. params[name.strip()] = node
  374. max_name_len = max_name(params.keys()) + 8
  375. # `ret_map` is a name:desc map.
  376. for name, node in params.items():
  377. desc = ''
  378. desc_node = get_child(node, 'parameterdescription')
  379. if desc_node:
  380. desc = fmt_node_as_vimhelp(
  381. desc_node, width=width, indent=(' ' * max_name_len))
  382. ret_map[name] = desc
  383. return ret_map
  384. def render_node(n, text, prefix='', indent='', width=62):
  385. """Renders a node as Vim help text, recursively traversing all descendants."""
  386. global fmt_vimhelp
  387. global has_seen_preformatted
  388. def ind(s):
  389. return s if fmt_vimhelp else ''
  390. text = ''
  391. # space_preceding = (len(text) > 0 and ' ' == text[-1][-1])
  392. # text += (int(not space_preceding) * ' ')
  393. if n.nodeName == 'preformatted':
  394. o = get_text(n, preformatted=True)
  395. ensure_nl = '' if o[-1] == '\n' else '\n'
  396. text += '>{}{}\n<'.format(ensure_nl, o)
  397. elif is_inline(n):
  398. text = doc_wrap(get_text(n), indent=indent, width=width)
  399. elif n.nodeName == 'verbatim':
  400. # TODO: currently we don't use this. The "[verbatim]" hint is there as
  401. # a reminder that we must decide how to format this if we do use it.
  402. text += ' [verbatim] {}'.format(get_text(n))
  403. elif n.nodeName == 'listitem':
  404. for c in n.childNodes:
  405. result = render_node(
  406. c,
  407. text,
  408. indent=indent + (' ' * len(prefix)),
  409. width=width
  410. )
  411. if is_blank(result):
  412. continue
  413. text += indent + prefix + result
  414. elif n.nodeName in ('para', 'heading'):
  415. for c in n.childNodes:
  416. text += render_node(c, text, indent=indent, width=width)
  417. elif n.nodeName == 'itemizedlist':
  418. for c in n.childNodes:
  419. text += '{}\n'.format(render_node(c, text, prefix='• ',
  420. indent=indent, width=width))
  421. elif n.nodeName == 'orderedlist':
  422. i = 1
  423. for c in n.childNodes:
  424. if is_blank(get_text(c)):
  425. text += '\n'
  426. continue
  427. text += '{}\n'.format(render_node(c, text, prefix='{}. '.format(i),
  428. indent=indent, width=width))
  429. i = i + 1
  430. elif n.nodeName == 'simplesect' and 'note' == n.getAttribute('kind'):
  431. text += '\nNote:\n '
  432. for c in n.childNodes:
  433. text += render_node(c, text, indent=' ', width=width)
  434. text += '\n'
  435. elif n.nodeName == 'simplesect' and 'warning' == n.getAttribute('kind'):
  436. text += 'Warning:\n '
  437. for c in n.childNodes:
  438. text += render_node(c, text, indent=' ', width=width)
  439. text += '\n'
  440. elif (n.nodeName == 'simplesect'
  441. and n.getAttribute('kind') in ('return', 'see')):
  442. text += ind(' ')
  443. for c in n.childNodes:
  444. text += render_node(c, text, indent=' ', width=width)
  445. elif n.nodeName == 'computeroutput':
  446. return get_text(n)
  447. else:
  448. raise RuntimeError('unhandled node type: {}\n{}'.format(
  449. n.nodeName, n.toprettyxml(indent=' ', newl='\n')))
  450. return text
  451. def para_as_map(parent, indent='', width=62):
  452. """Extracts a Doxygen XML <para> node to a map.
  453. Keys:
  454. 'text': Text from this <para> element
  455. 'params': <parameterlist> map
  456. 'return': List of @return strings
  457. 'seealso': List of @see strings
  458. 'xrefs': ?
  459. """
  460. chunks = {
  461. 'text': '',
  462. 'params': collections.OrderedDict(),
  463. 'return': [],
  464. 'seealso': [],
  465. 'xrefs': []
  466. }
  467. # Ordered dict of ordered lists.
  468. groups = collections.OrderedDict([
  469. ('params', []),
  470. ('return', []),
  471. ('seealso', []),
  472. ('xrefs', []),
  473. ])
  474. # Gather nodes into groups. Mostly this is because we want "parameterlist"
  475. # nodes to appear together.
  476. text = ''
  477. kind = ''
  478. last = ''
  479. if is_inline(parent):
  480. # Flatten inline text from a tree of non-block nodes.
  481. text = doc_wrap(render_node(parent, ""), indent=indent, width=width)
  482. else:
  483. prev = None # Previous node
  484. for child in parent.childNodes:
  485. if child.nodeName == 'parameterlist':
  486. groups['params'].append(child)
  487. elif child.nodeName == 'xrefsect':
  488. groups['xrefs'].append(child)
  489. elif child.nodeName == 'simplesect':
  490. last = kind
  491. kind = child.getAttribute('kind')
  492. if kind == 'return' or (kind == 'note' and last == 'return'):
  493. groups['return'].append(child)
  494. elif kind == 'see':
  495. groups['seealso'].append(child)
  496. elif kind in ('note', 'warning'):
  497. text += render_node(child, text, indent=indent, width=width)
  498. else:
  499. raise RuntimeError('unhandled simplesect: {}\n{}'.format(
  500. child.nodeName, child.toprettyxml(indent=' ', newl='\n')))
  501. else:
  502. if (prev is not None
  503. and is_inline(self_or_child(prev))
  504. and is_inline(self_or_child(child))
  505. and '' != get_text(self_or_child(child)).strip()
  506. and text
  507. and ' ' != text[-1]):
  508. text += ' '
  509. text += render_node(child, text, indent=indent, width=width)
  510. prev = child
  511. chunks['text'] += text
  512. # Generate map from the gathered items.
  513. if len(groups['params']) > 0:
  514. for child in groups['params']:
  515. update_params_map(child, ret_map=chunks['params'], width=width)
  516. for child in groups['return']:
  517. chunks['return'].append(render_node(
  518. child, '', indent=indent, width=width))
  519. for child in groups['seealso']:
  520. chunks['seealso'].append(render_node(
  521. child, '', indent=indent, width=width))
  522. for child in groups['xrefs']:
  523. # XXX: Add a space (or any char) to `title` here, otherwise xrefs
  524. # ("Deprecated" section) acts very weird...
  525. title = get_text(get_child(child, 'xreftitle')) + ' '
  526. xrefs.add(title)
  527. xrefdesc = get_text(get_child(child, 'xrefdescription'))
  528. chunks['xrefs'].append(doc_wrap(xrefdesc, prefix='{}: '.format(title),
  529. width=width) + '\n')
  530. return chunks
  531. def fmt_node_as_vimhelp(parent, width=62, indent=''):
  532. """Renders (nested) Doxygen <para> nodes as Vim :help text.
  533. NB: Blank lines in a docstring manifest as <para> tags.
  534. """
  535. rendered_blocks = []
  536. def fmt_param_doc(m):
  537. """Renders a params map as Vim :help text."""
  538. max_name_len = max_name(m.keys()) + 4
  539. out = ''
  540. for name, desc in m.items():
  541. name = ' {}'.format('{{{}}}'.format(name).ljust(max_name_len))
  542. out += '{}{}\n'.format(name, desc)
  543. return out.rstrip()
  544. def has_nonexcluded_params(m):
  545. """Returns true if any of the given params has at least
  546. one non-excluded item."""
  547. if fmt_param_doc(m) != '':
  548. return True
  549. for child in parent.childNodes:
  550. para = para_as_map(child, indent, width)
  551. # Generate text from the gathered items.
  552. chunks = [para['text']]
  553. if len(para['params']) > 0 and has_nonexcluded_params(para['params']):
  554. chunks.append('\nParameters: ~')
  555. chunks.append(fmt_param_doc(para['params']))
  556. if len(para['return']) > 0:
  557. chunks.append('\nReturn: ~')
  558. for s in para['return']:
  559. chunks.append(s)
  560. if len(para['seealso']) > 0:
  561. chunks.append('\nSee also: ~')
  562. for s in para['seealso']:
  563. chunks.append(s)
  564. for s in para['xrefs']:
  565. chunks.append(s)
  566. rendered_blocks.append(clean_lines('\n'.join(chunks).strip()))
  567. rendered_blocks.append('')
  568. return clean_lines('\n'.join(rendered_blocks).strip())
  569. def extract_from_xml(filename, target, width):
  570. """Extracts Doxygen info as maps without formatting the text.
  571. Returns two maps:
  572. 1. Functions
  573. 2. Deprecated functions
  574. The `fmt_vimhelp` global controls some special cases for use by
  575. fmt_doxygen_xml_as_vimhelp(). (TODO: ugly :)
  576. """
  577. global xrefs
  578. global fmt_vimhelp
  579. xrefs.clear()
  580. fns = {} # Map of func_name:docstring.
  581. deprecated_fns = {} # Map of func_name:docstring.
  582. dom = minidom.parse(filename)
  583. compoundname = get_text(dom.getElementsByTagName('compoundname')[0])
  584. for member in dom.getElementsByTagName('memberdef'):
  585. if member.getAttribute('static') == 'yes' or \
  586. member.getAttribute('kind') != 'function' or \
  587. member.getAttribute('prot') == 'private' or \
  588. get_text(get_child(member, 'name')).startswith('_'):
  589. continue
  590. loc = find_first(member, 'location')
  591. if 'private' in loc.getAttribute('file'):
  592. continue
  593. return_type = get_text(get_child(member, 'type'))
  594. if return_type == '':
  595. continue
  596. if return_type.startswith(('ArrayOf', 'DictionaryOf')):
  597. parts = return_type.strip('_').split('_')
  598. return_type = '{}({})'.format(parts[0], ', '.join(parts[1:]))
  599. name = get_text(get_child(member, 'name'))
  600. annotations = get_text(get_child(member, 'argsstring'))
  601. if annotations and ')' in annotations:
  602. annotations = annotations.rsplit(')', 1)[-1].strip()
  603. # XXX: (doxygen 1.8.11) 'argsstring' only includes attributes of
  604. # non-void functions. Special-case void functions here.
  605. if name == 'nvim_get_mode' and len(annotations) == 0:
  606. annotations += 'FUNC_API_FAST'
  607. annotations = filter(None, map(lambda x: annotation_map.get(x),
  608. annotations.split()))
  609. params = []
  610. type_length = 0
  611. for param in iter_children(member, 'param'):
  612. param_type = get_text(get_child(param, 'type')).strip()
  613. param_name = ''
  614. declname = get_child(param, 'declname')
  615. if declname:
  616. param_name = get_text(declname).strip()
  617. elif CONFIG[target]['mode'] == 'lua':
  618. # XXX: this is what lua2dox gives us...
  619. param_name = param_type
  620. param_type = ''
  621. if param_name in param_exclude:
  622. continue
  623. if fmt_vimhelp and param_type.endswith('*'):
  624. param_type = param_type.strip('* ')
  625. param_name = '*' + param_name
  626. type_length = max(type_length, len(param_type))
  627. params.append((param_type, param_name))
  628. # Handle Object Oriented style functions here.
  629. # We make sure they have "self" in the parameters,
  630. # and a parent function
  631. if return_type.startswith('function') \
  632. and len(return_type.split(' ')) >= 2 \
  633. and any(x[1] == 'self' for x in params):
  634. split_return = return_type.split(' ')
  635. name = f'{split_return[1]}:{name}'
  636. c_args = []
  637. for param_type, param_name in params:
  638. c_args.append((' ' if fmt_vimhelp else '') + (
  639. '%s %s' % (param_type.ljust(type_length), param_name)).strip())
  640. if not fmt_vimhelp:
  641. pass
  642. else:
  643. fstem = '?'
  644. if '.' in compoundname:
  645. fstem = compoundname.split('.')[0]
  646. fstem = CONFIG[target]['module_override'].get(fstem, fstem)
  647. vimtag = CONFIG[target]['fn_helptag_fmt'](fstem, name)
  648. prefix = '%s(' % name
  649. suffix = '%s)' % ', '.join('{%s}' % a[1] for a in params
  650. if a[0] not in ('void', 'Error'))
  651. if not fmt_vimhelp:
  652. c_decl = '%s %s(%s);' % (return_type, name, ', '.join(c_args))
  653. signature = prefix + suffix
  654. else:
  655. c_decl = textwrap.indent('%s %s(\n%s\n);' % (return_type, name,
  656. ',\n'.join(c_args)),
  657. ' ')
  658. # Minimum 8 chars between signature and vimtag
  659. lhs = (width - 8) - len(vimtag)
  660. if len(prefix) + len(suffix) > lhs:
  661. signature = vimtag.rjust(width) + '\n'
  662. signature += doc_wrap(suffix, width=width, prefix=prefix,
  663. func=True)
  664. else:
  665. signature = prefix + suffix
  666. signature += vimtag.rjust(width - len(signature))
  667. paras = []
  668. brief_desc = find_first(member, 'briefdescription')
  669. if brief_desc:
  670. for child in brief_desc.childNodes:
  671. paras.append(para_as_map(child))
  672. desc = find_first(member, 'detaileddescription')
  673. if desc:
  674. for child in desc.childNodes:
  675. paras.append(para_as_map(child))
  676. log.debug(
  677. textwrap.indent(
  678. re.sub(r'\n\s*\n+', '\n',
  679. desc.toprettyxml(indent=' ', newl='\n')), ' ' * 16))
  680. fn = {
  681. 'annotations': list(annotations),
  682. 'signature': signature,
  683. 'parameters': params,
  684. 'parameters_doc': collections.OrderedDict(),
  685. 'doc': [],
  686. 'return': [],
  687. 'seealso': [],
  688. }
  689. if fmt_vimhelp:
  690. fn['desc_node'] = desc # HACK :(
  691. for m in paras:
  692. if 'text' in m:
  693. if not m['text'] == '':
  694. fn['doc'].append(m['text'])
  695. if 'params' in m:
  696. # Merge OrderedDicts.
  697. fn['parameters_doc'].update(m['params'])
  698. if 'return' in m and len(m['return']) > 0:
  699. fn['return'] += m['return']
  700. if 'seealso' in m and len(m['seealso']) > 0:
  701. fn['seealso'] += m['seealso']
  702. if INCLUDE_C_DECL:
  703. fn['c_decl'] = c_decl
  704. if 'Deprecated' in str(xrefs):
  705. deprecated_fns[name] = fn
  706. elif name.startswith(CONFIG[target]['fn_name_prefix']):
  707. fns[name] = fn
  708. xrefs.clear()
  709. fns = collections.OrderedDict(sorted(
  710. fns.items(),
  711. key=lambda key_item_tuple: key_item_tuple[0].lower()))
  712. deprecated_fns = collections.OrderedDict(sorted(deprecated_fns.items()))
  713. return (fns, deprecated_fns)
  714. def fmt_doxygen_xml_as_vimhelp(filename, target):
  715. """Entrypoint for generating Vim :help from from Doxygen XML.
  716. Returns 3 items:
  717. 1. Vim help text for functions found in `filename`.
  718. 2. Vim help text for deprecated functions.
  719. """
  720. global fmt_vimhelp
  721. fmt_vimhelp = True
  722. fns_txt = {} # Map of func_name:vim-help-text.
  723. deprecated_fns_txt = {} # Map of func_name:vim-help-text.
  724. fns, _ = extract_from_xml(filename, target, width=text_width)
  725. for name, fn in fns.items():
  726. # Generate Vim :help for parameters.
  727. if fn['desc_node']:
  728. doc = fmt_node_as_vimhelp(fn['desc_node'])
  729. if not doc:
  730. doc = 'TODO: Documentation'
  731. annotations = '\n'.join(fn['annotations'])
  732. if annotations:
  733. annotations = ('\n\nAttributes: ~\n' +
  734. textwrap.indent(annotations, ' '))
  735. i = doc.rfind('Parameters: ~')
  736. if i == -1:
  737. doc += annotations
  738. else:
  739. doc = doc[:i] + annotations + '\n\n' + doc[i:]
  740. if INCLUDE_C_DECL:
  741. doc += '\n\nC Declaration: ~\n>\n'
  742. doc += fn['c_decl']
  743. doc += '\n<'
  744. func_doc = fn['signature'] + '\n'
  745. func_doc += textwrap.indent(clean_lines(doc), ' ' * 16)
  746. # Verbatim handling.
  747. func_doc = re.sub(r'^\s+([<>])$', r'\1', func_doc, flags=re.M)
  748. split_lines = func_doc.split('\n')
  749. start = 0
  750. while True:
  751. try:
  752. start = split_lines.index('>', start)
  753. except ValueError:
  754. break
  755. try:
  756. end = split_lines.index('<', start)
  757. except ValueError:
  758. break
  759. split_lines[start + 1:end] = [
  760. (' ' + x).rstrip()
  761. for x in textwrap.dedent(
  762. "\n".join(
  763. split_lines[start+1:end]
  764. )
  765. ).split("\n")
  766. ]
  767. start = end
  768. func_doc = "\n".join(split_lines)
  769. if 'Deprecated' in xrefs:
  770. deprecated_fns_txt[name] = func_doc
  771. elif name.startswith(CONFIG[target]['fn_name_prefix']):
  772. fns_txt[name] = func_doc
  773. xrefs.clear()
  774. fmt_vimhelp = False
  775. return ('\n\n'.join(list(fns_txt.values())),
  776. '\n\n'.join(list(deprecated_fns_txt.values())))
  777. def delete_lines_below(filename, tokenstr):
  778. """Deletes all lines below the line containing `tokenstr`, the line itself,
  779. and one line above it.
  780. """
  781. lines = open(filename).readlines()
  782. i = 0
  783. found = False
  784. for i, line in enumerate(lines, 1):
  785. if tokenstr in line:
  786. found = True
  787. break
  788. if not found:
  789. raise RuntimeError(f'not found: "{tokenstr}"')
  790. i = max(0, i - 2)
  791. with open(filename, 'wt') as fp:
  792. fp.writelines(lines[0:i])
  793. def main(config, args):
  794. """Generates:
  795. 1. Vim :help docs
  796. 2. *.mpack files for use by API clients
  797. Doxygen is called and configured through stdin.
  798. """
  799. for target in CONFIG:
  800. if args.target is not None and target != args.target:
  801. continue
  802. mpack_file = os.path.join(
  803. base_dir, 'runtime', 'doc',
  804. CONFIG[target]['filename'].replace('.txt', '.mpack'))
  805. if os.path.exists(mpack_file):
  806. os.remove(mpack_file)
  807. output_dir = out_dir.format(target=target)
  808. debug = args.log_level >= logging.DEBUG
  809. p = subprocess.Popen(
  810. ['doxygen', '-'],
  811. stdin=subprocess.PIPE,
  812. # silence warnings
  813. # runtime/lua/vim/lsp.lua:209: warning: argument 'foo' not found
  814. stderr=(subprocess.STDOUT if debug else subprocess.DEVNULL))
  815. p.communicate(
  816. config.format(
  817. input=CONFIG[target]['files'],
  818. output=output_dir,
  819. filter=filter_cmd,
  820. file_patterns=CONFIG[target]['file_patterns'])
  821. .encode('utf8')
  822. )
  823. if p.returncode:
  824. sys.exit(p.returncode)
  825. fn_map_full = {} # Collects all functions as each module is processed.
  826. sections = {}
  827. intros = {}
  828. sep = '=' * text_width
  829. base = os.path.join(output_dir, 'xml')
  830. dom = minidom.parse(os.path.join(base, 'index.xml'))
  831. # generate docs for section intros
  832. for compound in dom.getElementsByTagName('compound'):
  833. if compound.getAttribute('kind') != 'group':
  834. continue
  835. groupname = get_text(find_first(compound, 'name'))
  836. groupxml = os.path.join(base, '%s.xml' %
  837. compound.getAttribute('refid'))
  838. group_parsed = minidom.parse(groupxml)
  839. doc_list = []
  840. brief_desc = find_first(group_parsed, 'briefdescription')
  841. if brief_desc:
  842. for child in brief_desc.childNodes:
  843. doc_list.append(fmt_node_as_vimhelp(child))
  844. desc = find_first(group_parsed, 'detaileddescription')
  845. if desc:
  846. doc = fmt_node_as_vimhelp(desc)
  847. if doc:
  848. doc_list.append(doc)
  849. intros[groupname] = "\n".join(doc_list)
  850. for compound in dom.getElementsByTagName('compound'):
  851. if compound.getAttribute('kind') != 'file':
  852. continue
  853. filename = get_text(find_first(compound, 'name'))
  854. if filename.endswith('.c') or filename.endswith('.lua'):
  855. xmlfile = os.path.join(base,
  856. '{}.xml'.format(compound.getAttribute('refid')))
  857. # Extract unformatted (*.mpack).
  858. fn_map, _ = extract_from_xml(xmlfile, target, width=9999)
  859. # Extract formatted (:help).
  860. functions_text, deprecated_text = fmt_doxygen_xml_as_vimhelp(
  861. os.path.join(base, '{}.xml'.format(
  862. compound.getAttribute('refid'))), target)
  863. if not functions_text and not deprecated_text:
  864. continue
  865. else:
  866. name = os.path.splitext(
  867. os.path.basename(filename))[0].lower()
  868. sectname = name.upper() if name == 'ui' else name.title()
  869. doc = ''
  870. intro = intros.get(f'api-{name}')
  871. if intro:
  872. doc += '\n\n' + intro
  873. if functions_text:
  874. doc += '\n\n' + functions_text
  875. if INCLUDE_DEPRECATED and deprecated_text:
  876. doc += f'\n\n\nDeprecated {sectname} Functions: ~\n\n'
  877. doc += deprecated_text
  878. if doc:
  879. filename = os.path.basename(filename)
  880. sectname = CONFIG[target]['section_name'].get(
  881. filename, sectname)
  882. title = CONFIG[target]['section_fmt'](sectname)
  883. helptag = CONFIG[target]['helptag_fmt'](sectname)
  884. sections[filename] = (title, helptag, doc)
  885. fn_map_full.update(fn_map)
  886. if len(sections) == 0:
  887. fail(f'no sections for target: {target}')
  888. if len(sections) > len(CONFIG[target]['section_order']):
  889. raise RuntimeError(
  890. 'found new modules "{}"; update the "section_order" map'.format(
  891. set(sections).difference(CONFIG[target]['section_order'])))
  892. docs = ''
  893. i = 0
  894. for filename in CONFIG[target]['section_order']:
  895. try:
  896. title, helptag, section_doc = sections.pop(filename)
  897. except KeyError:
  898. msg(f'warning: empty docs, skipping (target={target}): {filename}')
  899. msg(f' existing docs: {sections.keys()}')
  900. continue
  901. i += 1
  902. if filename not in CONFIG[target]['append_only']:
  903. docs += sep
  904. docs += '\n%s%s' % (title,
  905. helptag.rjust(text_width - len(title)))
  906. docs += section_doc
  907. docs += '\n\n\n'
  908. docs = docs.rstrip() + '\n\n'
  909. docs += ' vim:tw=78:ts=8:ft=help:norl:\n'
  910. doc_file = os.path.join(base_dir, 'runtime', 'doc',
  911. CONFIG[target]['filename'])
  912. delete_lines_below(doc_file, CONFIG[target]['section_start_token'])
  913. with open(doc_file, 'ab') as fp:
  914. fp.write(docs.encode('utf8'))
  915. fn_map_full = collections.OrderedDict(sorted(fn_map_full.items()))
  916. with open(mpack_file, 'wb') as fp:
  917. fp.write(msgpack.packb(fn_map_full, use_bin_type=True))
  918. if not args.keep_tmpfiles:
  919. shutil.rmtree(output_dir)
  920. msg_report()
  921. def filter_source(filename):
  922. name, extension = os.path.splitext(filename)
  923. if extension == '.lua':
  924. p = subprocess.run([lua2dox_filter, filename], stdout=subprocess.PIPE)
  925. op = ('?' if 0 != p.returncode else p.stdout.decode('utf-8'))
  926. print(op)
  927. else:
  928. """Filters the source to fix macros that confuse Doxygen."""
  929. with open(filename, 'rt') as fp:
  930. print(re.sub(r'^(ArrayOf|DictionaryOf)(\(.*?\))',
  931. lambda m: m.group(1)+'_'.join(
  932. re.split(r'[^\w]+', m.group(2))),
  933. fp.read(), flags=re.M))
  934. def parse_args():
  935. targets = ', '.join(CONFIG.keys())
  936. ap = argparse.ArgumentParser()
  937. ap.add_argument(
  938. "--log-level", "-l", choices=LOG_LEVELS.keys(),
  939. default=logging.getLevelName(logging.ERROR), help="Set log verbosity"
  940. )
  941. ap.add_argument('source_filter', nargs='*',
  942. help="Filter source file(s)")
  943. ap.add_argument('-k', '--keep-tmpfiles', action='store_true',
  944. help="Keep temporary files")
  945. ap.add_argument('-t', '--target',
  946. help=f'One of ({targets}), defaults to "all"')
  947. return ap.parse_args()
  948. Doxyfile = textwrap.dedent('''
  949. OUTPUT_DIRECTORY = {output}
  950. INPUT = {input}
  951. INPUT_ENCODING = UTF-8
  952. FILE_PATTERNS = {file_patterns}
  953. RECURSIVE = YES
  954. INPUT_FILTER = "{filter}"
  955. EXCLUDE =
  956. EXCLUDE_SYMLINKS = NO
  957. EXCLUDE_PATTERNS = */private/*
  958. EXCLUDE_SYMBOLS =
  959. EXTENSION_MAPPING = lua=C
  960. EXTRACT_PRIVATE = NO
  961. GENERATE_HTML = NO
  962. GENERATE_DOCSET = NO
  963. GENERATE_HTMLHELP = NO
  964. GENERATE_QHP = NO
  965. GENERATE_TREEVIEW = NO
  966. GENERATE_LATEX = NO
  967. GENERATE_RTF = NO
  968. GENERATE_MAN = NO
  969. GENERATE_DOCBOOK = NO
  970. GENERATE_AUTOGEN_DEF = NO
  971. GENERATE_XML = YES
  972. XML_OUTPUT = xml
  973. XML_PROGRAMLISTING = NO
  974. ENABLE_PREPROCESSING = YES
  975. MACRO_EXPANSION = YES
  976. EXPAND_ONLY_PREDEF = NO
  977. MARKDOWN_SUPPORT = YES
  978. ''')
  979. if __name__ == "__main__":
  980. args = parse_args()
  981. print("Setting log level to %s" % args.log_level)
  982. args.log_level = LOG_LEVELS[args.log_level]
  983. log.setLevel(args.log_level)
  984. if len(args.source_filter) > 0:
  985. filter_source(args.source_filter[0])
  986. else:
  987. main(Doxyfile, args)
  988. # vim: set ft=python ts=4 sw=4 tw=79 et :