1import os
2import pytest  # type: ignore
3
4import sphinx.addnodes
5import sphinx.environment
6from breathe.parser.compound import (
7    compounddefTypeSub, linkedTextTypeSub, memberdefTypeSub, paramTypeSub,
8    refTypeSub, MixedContainer
9)
10from breathe.renderer.sphinxrenderer import SphinxRenderer
11from breathe.renderer.filter import OpenFilter
12from docutils import frontend, nodes, parsers, utils
13
14from sphinx.testing.fixtures import (
15    test_params, app_params, make_app, shared_result,
16    sphinx_test_tempdir, rootdir
17)
18from sphinx.testing.path import path
19
20sphinx.locale.init([], '')
21
22
23@pytest.fixture(scope='function')
24def app(test_params, app_params, make_app, shared_result):
25    """
26    Based on sphinx.testing.fixtures.app
27    """
28    args, kwargs = app_params
29    assert 'srcdir' in kwargs
30    kwargs['srcdir'].makedirs(exist_ok=True)
31    (kwargs['srcdir'] / 'conf.py').write_text('')
32    app_ = make_app(*args, **kwargs)
33    yield app_
34
35    print('# testroot:', kwargs.get('testroot', 'root'))
36    print('# builder:', app_.builder.name)
37    print('# srcdir:', app_.srcdir)
38    print('# outdir:', app_.outdir)
39    print('# status:', '\n' + app_._status.getvalue())
40    print('# warning:', '\n' + app_._warning.getvalue())
41
42    if test_params['shared_result']:
43        shared_result.store(test_params['shared_result'], app_)
44
45
46class WrappedDoxygenNode:
47    """
48    A base class for test wrappers of Doxygen nodes. It allows setting all attributes via keyword arguments
49    in the constructor.
50    """
51    def __init__(self, cls, *args, **kwargs):
52        if cls:
53            cls.__init__(self, args)
54        for name, value in kwargs.items():
55            if not hasattr(self, name):
56                raise AttributeError('invalid attribute ' + name)
57            setattr(self, name, value)
58
59
60class WrappedMixedContainer(MixedContainer, WrappedDoxygenNode):
61    """A test wrapper of Doxygen mixed container."""
62    def __init__(self, **kwargs):
63        MixedContainer.__init__(self, None, None, None, None)
64        WrappedDoxygenNode.__init__(self, None, **kwargs)
65
66
67class WrappedLinkedText(linkedTextTypeSub, WrappedDoxygenNode):
68    """A test wrapper of Doxygen linked text."""
69    def __init__(self, **kwargs):
70        WrappedDoxygenNode.__init__(self, linkedTextTypeSub, **kwargs)
71
72
73class WrappedMemberDef(memberdefTypeSub, WrappedDoxygenNode):
74    """A test wrapper of Doxygen class/file/namespace member symbol such as a function declaration."""
75    def __init__(self, **kwargs):
76        WrappedDoxygenNode.__init__(self, memberdefTypeSub, **kwargs)
77
78
79class WrappedParam(paramTypeSub, WrappedDoxygenNode):
80    """A test wrapper of Doxygen parameter."""
81    def __init__(self, **kwargs):
82        WrappedDoxygenNode.__init__(self, paramTypeSub, **kwargs)
83
84
85class WrappedRef(refTypeSub, WrappedDoxygenNode):
86    """A test wrapper of Doxygen ref."""
87    def __init__(self, node_name, **kwargs):
88        WrappedDoxygenNode.__init__(self, refTypeSub, node_name, **kwargs)
89
90
91class WrappedCompoundDef(compounddefTypeSub, WrappedDoxygenNode):
92    """A test wrapper of Doxygen compound definition."""
93    def __init__(self, **kwargs):
94        WrappedDoxygenNode.__init__(self, compounddefTypeSub, **kwargs)
95
96
97class MockState:
98    def __init__(self, app):
99        env = sphinx.environment.BuildEnvironment(app)
100        env.setup(app)
101        env.temp_data['docname'] = 'mock-doc'
102        settings = frontend.OptionParser(
103            components=(parsers.rst.Parser,)).get_default_values()
104        settings.env = env
105        self.document = utils.new_document('', settings)
106
107    def nested_parse(self, content, content_offset, contentnode):
108        pass
109
110
111class MockReporter:
112    def __init__(self):
113        pass
114
115    def warning(self, description, line):
116        pass
117
118    def debug(self, message):
119        pass
120
121
122class MockStateMachine:
123    def __init__(self):
124        self.reporter = MockReporter()
125
126    def get_source_and_line(self, lineno: int):
127        if lineno is None:
128            lineno = 42
129        return 'mock-doc', lineno
130
131
132class MockMaskFactory:
133    def __init__(self):
134        pass
135
136    def mask(self, node):
137        return node
138
139
140class MockContext:
141    def __init__(self, app, node_stack, domain=None, options=[]):
142        self.domain = domain
143        self.node_stack = node_stack
144        self.directive_args = [
145            None,     # name
146            None,     # arguments
147            options,  # options
148            None,     # content
149            None,     # lineno
150            None,     # content_offset
151            None,     # block_text
152            MockState(app), MockStateMachine()]
153        self.child = None
154        self.mask_factory = MockMaskFactory()
155
156    def create_child_context(self, attribute):
157        return self
158
159
160class MockTargetHandler:
161    def __init__(self):
162        pass
163
164    def create_target(self, refid):
165        pass
166
167
168class MockDocument:
169    def __init__(self):
170        self.reporter = MockReporter()
171
172
173class MockCompoundParser:
174    """
175    A compound parser reads a doxygen XML file from disk; this mock implements
176    a mapping of what would be the file name on disk to data using a dict.
177    """
178    def __init__(self, compound_dict):
179        self.compound_dict = compound_dict
180
181    class MockFileData:
182        def __init__(self, compounddef):
183            self.compounddef = compounddef
184
185    def parse(self, compoundname):
186        compounddef = self.compound_dict[compoundname]
187        return self.MockFileData(compounddef)
188
189
190class NodeFinder(nodes.NodeVisitor):
191    """Find node with specified class name."""
192    def __init__(self, name, document):
193        nodes.NodeVisitor.__init__(self, document)
194        self.name = name
195        self.found_nodes = []
196
197    def unknown_visit(self, node):
198        if node.__class__.__name__ == self.name:
199            self.found_nodes.append(node)
200
201
202def find_nodes(nodes, name):
203    """Find all docutils nodes with specified class name in *nodes*."""
204    finder = NodeFinder(name, MockDocument())
205    for node in nodes:
206        node.walk(finder)
207    return finder.found_nodes
208
209
210def find_node(nodes, name):
211    """
212    Find a single docutils node with specified class name in *nodes*.
213    Throw an exception if there isn't exactly one such node.
214    """
215    found_nodes = find_nodes(nodes, name)
216    if len(found_nodes) != 1:
217        raise Exception('the number of nodes {0} is {1}'.format(name, len(found_nodes)))
218    return found_nodes[0]
219
220
221def test_find_nodes():
222    section = nodes.section()
223    foo = nodes.Text('foo')
224    desc = nodes.description()
225    bar = nodes.Text('bar')
226    section.children = [foo, desc, bar]
227    assert(find_nodes(section, 'description') == [desc])
228    assert(find_nodes([section, desc], 'description') == [desc, desc])
229    assert(find_nodes([], 'description') == [])
230    assert(find_nodes(section, 'unknown') == [])
231    assert(find_nodes(section, 'Text') == [foo, bar])
232
233
234def check_exception(func, message):
235    """Check if func() throws an exception with the specified message."""
236    exception = None
237    try:
238        func()
239    except Exception as e:
240        exception = e
241    print(str(exception))
242    assert exception and str(exception) == message
243
244
245def test_find_node():
246    section = nodes.section()
247    foo = nodes.Text('foo')
248    desc = nodes.description()
249    bar = nodes.Text('bar')
250    section.children = [foo, desc, bar]
251    assert(find_node(section, 'description') == desc)
252    check_exception(lambda: find_node([section, desc], 'description'),
253                    'the number of nodes description is 2')
254    check_exception(lambda: find_node([], 'description'),
255                    'the number of nodes description is 0')
256    check_exception(lambda: find_node([section], 'unknown'),
257                    'the number of nodes unknown is 0')
258    check_exception(lambda: find_node([section], 'Text'),
259                    'the number of nodes Text is 2')
260
261
262def render(app, member_def, domain=None, show_define_initializer=False,
263           compound_parser=None, options=[]):
264    """Render Doxygen *member_def* with *renderer_class*."""
265
266    app.config.breathe_separate_member_pages = False
267    app.config.breathe_use_project_refids = False
268    app.config.breathe_show_define_initializer = show_define_initializer
269    app.config.breathe_order_parameters_first = False
270    app.config.breathe_debug_trace_directives = False
271    app.config.breathe_debug_trace_doxygen_ids = False
272    app.config.breathe_debug_trace_qualification = False
273    renderer = SphinxRenderer(app,
274                              None,  # project_info
275                              [],    # node_stack
276                              None,  # state
277                              None,  # document
278                              MockTargetHandler(),
279                              compound_parser,
280                              OpenFilter())
281    renderer.context = MockContext(app, [member_def], domain, options)
282    return renderer.render(member_def)
283
284
285def test_render_func(app):
286    member_def = WrappedMemberDef(kind='function', definition='void foo', type_='void', name='foo', argsstring='(int)',
287                                  virt='non-virtual',
288                                  param=[WrappedParam(type_=WrappedLinkedText(content_=[WrappedMixedContainer(value=u'int')]))])
289    signature = find_node(render(app, member_def), 'desc_signature')
290    assert signature.astext().startswith('void')
291    if sphinx.version_info[0] < 4:
292        assert find_node(signature, 'desc_name')[0] == 'foo'
293    else:
294        n = find_node(signature, 'desc_name')[0]
295        assert isinstance(n, sphinx.addnodes.desc_sig_name)
296        assert len(n) == 1
297        assert n[0] == 'foo'
298    params = find_node(signature, 'desc_parameterlist')
299    assert len(params) == 1
300    param = params[0]
301    if sphinx.version_info[0] < 4:
302        assert param[0] == 'int'
303    else:
304        assert isinstance(param[0], sphinx.addnodes.desc_sig_keyword_type)
305        assert param[0][0] == 'int'
306
307
308def test_render_typedef(app):
309    member_def = WrappedMemberDef(kind='typedef', definition='typedef int foo', type_='int', name='foo')
310    signature = find_node(render(app, member_def), 'desc_signature')
311    assert signature.astext() == 'typedef int foo'
312
313
314def test_render_c_typedef(app):
315    member_def = WrappedMemberDef(kind='typedef', definition='typedef unsigned int bar', type_='unsigned int', name='bar')
316    signature = find_node(render(app, member_def, domain='c'), 'desc_signature')
317    assert signature.astext() == 'typedef unsigned int bar'
318
319
320def test_render_c_function_typedef(app):
321    member_def = WrappedMemberDef(kind='typedef', definition='typedef void* (*voidFuncPtr)(float, int)',
322                                  type_='void* (*', name='voidFuncPtr', argsstring=')(float, int)')
323    signature = find_node(render(app, member_def, domain='c'), 'desc_signature')
324    assert signature.astext().startswith('typedef void *')
325    if sphinx.version_info[0] < 4:
326        params = find_node(signature, 'desc_parameterlist')
327        assert len(params) == 2
328        assert params[0].astext() == "float"
329        assert params[1].astext() == "int"
330    else:
331        # the use of desc_parameterlist in this case was not correct,
332        # it should only be used for a top-level function
333        pass
334
335
336def test_render_using_alias(app):
337    member_def = WrappedMemberDef(kind='typedef', definition='using foo = int', type_='int', name='foo')
338    signature = find_node(render(app, member_def), 'desc_signature')
339    assert signature.astext() == 'using foo = int'
340
341
342def test_render_const_func(app):
343    member_def = WrappedMemberDef(kind='function', definition='void f', type_='void', name='f', argsstring='() const',
344                               virt='non-virtual', const='yes')
345    signature = find_node(render(app, member_def), 'desc_signature')
346    assert '_CPPv2NK1fEv' in signature['ids']
347
348
349def test_render_lvalue_func(app):
350    member_def = WrappedMemberDef(kind='function', definition='void f', type_='void', name='f', argsstring='() &',
351                               virt='non-virtual', refqual='lvalue')
352    signature = find_node(render(app, member_def), 'desc_signature')
353    assert signature.astext().endswith('&')
354
355
356def test_render_rvalue_func(app):
357    member_def = WrappedMemberDef(kind='function', definition='void f', type_='void', name='f', argsstring='() &&',
358                               virt='non-virtual', refqual='rvalue')
359    signature = find_node(render(app, member_def), 'desc_signature')
360    assert signature.astext().endswith('&&')
361
362
363def test_render_const_lvalue_func(app):
364    member_def = WrappedMemberDef(kind='function', definition='void f', type_='void', name='f',argsstring='() const &',
365                               virt='non-virtual', const='yes', refqual='lvalue')
366    signature = find_node(render(app, member_def), 'desc_signature')
367    assert signature.astext().endswith('const &')
368
369
370def test_render_const_rvalue_func(app):
371    member_def = WrappedMemberDef(kind='function', definition='void f', type_='void', name='f', argsstring='() const &&',
372                               virt='non-virtual', const='yes', refqual='rvalue')
373    signature = find_node(render(app, member_def), 'desc_signature')
374    assert signature.astext().endswith('const &&')
375
376
377def test_render_variable_initializer(app):
378    member_def = WrappedMemberDef(kind='variable', definition='const int EOF', type_='const int', name='EOF',
379                                  initializer=WrappedMixedContainer(value=u'= -1'))
380    signature = find_node(render(app, member_def), 'desc_signature')
381    assert signature.astext() == 'const int EOF = -1'
382
383
384def test_render_define_initializer(app):
385    member_def = WrappedMemberDef(kind='define', name='MAX_LENGTH',
386                               initializer=WrappedLinkedText(content_=[WrappedMixedContainer(value=u'100')]))
387    signature_w_initializer = find_node(render(app, member_def, show_define_initializer=True), 'desc_signature')
388    assert signature_w_initializer.astext() == 'MAX_LENGTH 100'
389
390    member_def_no_show = WrappedMemberDef(kind='define', name='MAX_LENGTH_NO_INITIALIZER',
391                               initializer=WrappedLinkedText(content_=[WrappedMixedContainer(value=u'100')]))
392
393    signature_wo_initializer = find_node(render(app, member_def_no_show, show_define_initializer=False), 'desc_signature')
394    assert signature_wo_initializer.astext() == 'MAX_LENGTH_NO_INITIALIZER'
395
396
397def test_render_define_no_initializer(app):
398    sphinx.addnodes.setup(app)
399    member_def = WrappedMemberDef(kind='define', name='USE_MILK')
400    signature = find_node(render(app, member_def), 'desc_signature')
401    assert signature.astext() == 'USE_MILK'
402
403
404def test_render_innergroup(app):
405    refid = 'group__innergroup'
406    mock_compound_parser = MockCompoundParser({
407        refid: WrappedCompoundDef(kind='group',
408                                  compoundname='InnerGroup',
409                                  briefdescription='InnerGroup')
410    })
411    ref = WrappedRef('InnerGroup', refid=refid)
412    compound_def = WrappedCompoundDef(kind='group',
413                                      compoundname='OuterGroup',
414                                      briefdescription='OuterGroup',
415                                      innergroup=[ref])
416    assert all(el.astext() != 'InnerGroup'
417               for el in render(app, compound_def,
418                                compound_parser=mock_compound_parser))
419    assert any(el.astext() == 'InnerGroup'
420               for el in render(app, compound_def,
421                                compound_parser=mock_compound_parser,
422                                options=['inner']))
423
424def get_directive(app):
425    from breathe.directives.function import DoxygenFunctionDirective
426    from breathe.project import ProjectInfoFactory
427    from breathe.parser import DoxygenParserFactory
428    from breathe.finder.factory import FinderFactory
429    from docutils.statemachine import StringList
430    app.config.breathe_separate_member_pages = False
431    app.config.breathe_default_project = 'test_project'
432    app.config.breathe_domain_by_extension = {}
433    app.config.breathe_domain_by_file_pattern = {}
434    app.config.breathe_use_project_refids = False
435    project_info_factory = ProjectInfoFactory(app)
436    parser_factory = DoxygenParserFactory(app)
437    finder_factory = FinderFactory(app, parser_factory)
438    cls_args = ('doxygenclass', ['at::Tensor'],
439                {'members': '', 'protected-members': None, 'undoc-members': None},
440                StringList([], items=[]),
441                20, 24,
442                ('.. doxygenclass:: at::Tensor\n   :members:\n'
443                        '   :protected-members:\n   :undoc-members:'),
444                MockState(app),
445                MockStateMachine(),
446               )
447    return DoxygenFunctionDirective(finder_factory, project_info_factory, parser_factory, *cls_args)
448
449
450def get_matches(datafile):
451    from breathe.parser.compoundsuper import sectiondefType
452    from xml.dom import minidom
453
454    argsstrings = []
455    with open(os.path.join(os.path.dirname(__file__), 'data', datafile)) as fid:
456        xml = fid.read()
457    doc = minidom.parseString(xml)
458
459    sectiondef = sectiondefType.factory()
460    for child in doc.documentElement.childNodes:
461        sectiondef.buildChildren(child, 'memberdef')
462        if getattr(child, 'tagName', None) == 'memberdef':
463            # Get the argsstring function declaration
464            argsstrings.append(child.getElementsByTagName('argsstring')[0].childNodes[0].data)
465    matches = [[m, sectiondef] for m in sectiondef.memberdef]
466    return argsstrings, matches
467
468
469def test_resolve_overrides(app):
470    # Test that multiple function overrides works
471    argsstrings, matches = get_matches('arange.xml')
472    cls = get_directive(app)
473
474    # Verify that the exact arguments returns one override
475    for args in argsstrings:
476        ast_param = cls._parse_args(args)
477        ret = cls._resolve_function(matches, ast_param, None)
478
479def test_ellipsis(app):
480    argsstrings, matches = get_matches('ellipsis.xml')
481    cls = get_directive(app)
482
483    # Verify that parsing an ellipsis works
484    ast_param = cls._parse_args(argsstrings[0])
485    ret = cls._resolve_function(matches, ast_param, None)
486
487