Merge pull request #60 from MarcelKoch/parse-exception-qualifier
Parse exception and ref qualifier
diff --git a/examples/my_lib.h b/examples/my_lib.h
index 3591102..89c774d 100644
--- a/examples/my_lib.h
+++ b/examples/my_lib.h
@@ -1,4 +1,5 @@
#include <string>
+#include <QObject>
/**
* Example documented function
@@ -18,8 +19,10 @@
/// @{
namespace my_namespace
{
- class MyClass
+ class MyClass: public QObject
{
+ Q_OBJECT
+ Q_PROPERTY(double my_method READ my_method);
public:
MyClass();
diff --git a/sphinxcontrib/doxylink/doxylink.py b/sphinxcontrib/doxylink/doxylink.py
index a9ca04b..9a5411b 100644
--- a/sphinxcontrib/doxylink/doxylink.py
+++ b/sphinxcontrib/doxylink/doxylink.py
@@ -1,3 +1,4 @@
+import bisect
import os
import re
import requests
@@ -6,7 +7,7 @@
import xml.etree.ElementTree as ET
import urllib.parse
from collections import namedtuple
-from typing import Dict, Iterable, MutableMapping, Set, Union
+from typing import List, Optional, Union
from dateutil.parser import parse as parsedate
from docutils import nodes, utils
@@ -20,7 +21,61 @@
from . import __version__
from .parsing import normalise, ParseException
-Entry = namedtuple('Entry', ['kind', 'file'])
+
+class Entry(namedtuple('_Entry', ['name', 'kind', 'file', 'arglist'])):
+ '''Represents a documentation entry produced by Doxygen.'''
+
+ def matches(self, name: str, kind: Optional[str], arglist: Optional[str]) -> bool:
+ '''
+ Checks whether this entry has the specified name, kind, and argument list.
+
+ Args:
+ name (str): symbol name
+ kind (Optional[str]): restrict to symbols of this kind
+ arglist (Optional[str]): normalized argument list for overload resolution
+ '''
+
+ # Are we of the correct kind?
+ if kind and self.kind != kind:
+ return False
+
+ # Ensure the name matches
+ if not self.name.endswith(name):
+ return False
+
+ # "do_foo" doesn't match "foo"
+ prefix = self.name[:-len(name)]
+ if prefix and (prefix[-1].isidentifier() or prefix[-1].isnumeric()):
+ return False
+
+ if not arglist:
+ # If no argument list is provided, anything matches
+ return True
+
+ return self.arglist == arglist
+
+
+ def __lt__(self, other: Union["Entry", str]) -> bool: # type:ignore
+ '''
+ Compares entries for sorting by reverse name. This allows `SymbolMap` to
+ match "foo::bar" when searching for "bar".
+ '''
+
+ if isinstance(other, Entry):
+ return self.name[::-1] < other.name[::-1]
+ return self.name[::-1] < other
+
+
+ @property
+ def is_class(self) -> bool:
+ '''Returns true if this is a class entry (``kind`` is ``"class"``)'''
+ return self.kind == 'class'
+
+
+ @property
+ def is_template(self) -> bool:
+ '''Returns true if this is a template entry'''
+ return '<' in self.name
def report_info(env, msg, docname=None, lineno=None):
@@ -77,110 +132,122 @@
return bool(re.match(regex, str_to_validate))
-class FunctionList:
- """A FunctionList maps argument lists to specific entries"""
- def __init__(self):
- self.kind = 'function_list'
- self._arglist: MutableMapping[str, str] = {}
-
- def __getitem__(self, arglist: str) -> Entry:
- # If the user has requested a specific function through specifying an arglist then get the right anchor
- if arglist:
- try:
- filename = self._arglist[arglist]
- except KeyError:
- # TODO Offer fuzzy suggestion
- raise LookupError('Argument list match not found')
- else:
- # Otherwise just return the first entry (if they don't care they get whatever comes first)
- filename = list(self._arglist.values())[0]
-
- return Entry(kind='function', file=filename)
-
- def add_overload(self, arglist: str, file: str) -> None:
- self._arglist[arglist] = file
-
- def __repr__(self):
- return f"FunctionList({self._arglist})"
-
-
class SymbolMap:
- """A SymbolMap maps symbols to Entries or FunctionLists"""
+ """A SymbolMap maps symbols to Entries."""
def __init__(self, xml_doc: ET.ElementTree) -> None:
- self._mapping = parse_tag_file(xml_doc)
+ entries = parse_tag_file(xml_doc)
- def _get_symbol_match(self, symbol: str) -> str:
- if self._mapping.get(symbol):
- return symbol
+ # Sort the entry list for use with bisect
+ self._entries = sorted(entries)
- piecewise_list = match_piecewise(self._mapping.keys(), symbol)
- # If there is only one match, return it.
- if len(piecewise_list) == 1:
- return list(piecewise_list)[0]
+ def _find_entries(self, name: str, kind: Optional[str], arglist: Optional[str]) -> List[Entry]:
+ '''
+ Finds all potentially matching entries in the symbol list.
- # If there is more than one item in piecewise_list then there is an ambiguity
+ Args:
+ name (str): the name symbol to search for
+ kind (Optional[str]): the kind of symbols to search for
+ arglist (str): normalised function argument list
+
+ Returns:
+ list[Entry]: all entries whose name ends with 'name'
+ '''
+
+ matches = []
+
+ # Thanks to the sorting, all we need to do is iterate from the first to
+ # the last matching entry.
+ start = bisect.bisect_left(self._entries, name[::-1]) # type:ignore
+ for candidate in self._entries[start:]:
+ if not candidate.name.endswith(name):
+ # Reached the end of entries that end in 'name'
+ break
+
+ if candidate.matches(name, kind, arglist):
+ # Found one
+ matches.append(candidate)
+
+ return matches
+
+
+ def _disambiguate(self, name: str, candidates: List[Entry]) -> Entry:
+ '''
+ Returns the best-fitting candidate for the given symbol name. All
+ candidates are expected to be valid.
+
+ Args:
+ name (str): symbol name
+ candidates (list[Entry]): list of candidates to choose from
+
+ Returns:
+ Entry: the best candidate
+ '''
+
+ if not candidates:
+ raise LookupError(f'No documentation entry matching "{name}"')
+
+ # An exact match would appear at the beginning of the list.
+ if len(candidates) == 1 or candidates[0].name == name:
+ return candidates[0]
+
+ # If there is more than one candidate then there is an ambiguity
# Often this is due to the symbol matching the name of the constructor as well as the class name itself
# We will prefer the class
- classes_list = {s for s in piecewise_list if self._mapping[s].kind == 'class'}
+ classes = [c for c in candidates if c.is_class]
# If there is only one by here we return it.
- if len(classes_list) == 1:
- return list(classes_list)[0]
+ if len(classes) == 1:
+ return classes[0]
# Now, to disambiguate between ``PolyVox::Array< 1, ElementType >::operator[]`` and ``PolyVox::Array::operator[]`` matching ``operator[]``,
# we will ignore templated (as in C++ templates) tag names by removing names containing ``<``
- no_templates_list = {s for s in piecewise_list if '<' not in s}
+ no_templates = [c for c in candidates if not c.is_template]
- if len(no_templates_list) == 1:
- return list(no_templates_list)[0]
+ if len(no_templates) == 1:
+ return no_templates[0]
# If not found by now, return the shortest match, assuming that's the most specific
- if no_templates_list:
+ if no_templates:
# TODO return a warning here?
- return min(no_templates_list, key=len)
+ return min(no_templates, key=lambda entry: len(entry.name))
# TODO Offer fuzzy suggestion
raise LookupError('Could not find a match')
+
def __getitem__(self, item: str) -> Entry:
symbol, normalised_arglist = normalise(item)
- matched_symbol = self._get_symbol_match(symbol)
- entry = self._mapping[matched_symbol]
-
- if isinstance(entry, FunctionList):
- entry = entry[normalised_arglist]
-
- return entry
+ # Restrict to functions when given an argument list
+ kind = 'function' if normalised_arglist else None
+ candidates = self._find_entries(symbol, kind, normalised_arglist)
+ return self._disambiguate(symbol, candidates)
-def parse_tag_file(doc: ET.ElementTree) -> Dict[str, Union[Entry, FunctionList]]:
+def parse_tag_file(doc: ET.ElementTree) -> List[Entry]:
"""
- Takes in an XML tree from a Doxygen tag file and returns a dictionary that looks something like:
+ Takes in an XML tree from a Doxygen tag file and returns a list that looks something like:
.. code-block:: python
- {'PolyVox': Entry(...),
- 'PolyVox::Array': Entry(...),
- 'PolyVox::Array1DDouble': Entry(...),
- 'PolyVox::Array1DFloat': Entry(...),
- 'PolyVox::Array1DInt16': Entry(...),
- 'QScriptContext::throwError': FunctionList(...),
- 'QScriptContext::toString': FunctionList(...)
- }
-
- Note the different form for functions. This is required to allow for 'overloading by argument type'.
+ [Entry('PolyVox', ...),
+ Entry('PolyVox::Array', ...),
+ Entry('PolyVox::Array1DDouble'),
+ Entry('PolyVox::Array1DFloat'),
+ Entry('PolyVox::Array1DInt16'),
+ Entry('QScriptContext::throwError'),
+ Entry('QScriptContext::toString'),
+ ]
:Parameters:
doc : xml.etree.ElementTree
The XML DOM object
- :return: a dictionary mapping fully qualified symbols to files
+ :return: a list of entries mapping fully qualified symbols to files
"""
- mapping: Dict[str, Union[Entry, FunctionList]] = {}
- function_list = [] # This is a list of function to be parsed and inserted into mapping at the end of the function.
+ entries: List[Entry] = []
for compound in doc.findall('./compound'):
compound_kind = compound.get('kind')
if compound_kind not in {'namespace', 'class', 'struct', 'file', 'define', 'group', 'page'}:
@@ -201,7 +268,7 @@
compound_filename = compound_filename + '.html'
# If it's a compound we can simply add it
- mapping[compound_name] = Entry(kind=compound_kind, file=compound_filename)
+ entries.append(Entry(compound_name, kind=compound_kind, file=compound_filename, arglist=None))
for member in compound.findall('member'):
# If the member doesn't have an <anchorfile> element, use the parent compounds <filename> instead
@@ -212,52 +279,23 @@
raise KeyError(f"Member of {compound_name} does not have a name")
member_symbol = compound_name + '::' + member_name
member_kind = member.get('kind')
- arglist_text = member.findtext('./arglist') # If it has an <arglist> then we assume it's a function. Empty <arglist> returns '', not None. Things like typedefs and enums can have empty arglists
+ arglist = member.findtext('./arglist') # If it has an <arglist> then we assume it's a function. Empty <arglist> returns '', not None. Things like typedefs and enums can have empty arglists
- if member_kind == "friend": # ignore friend class definitions because it results in double class entries that will throw a RuntimeError (see below at the end of this function)
- continue
- if arglist_text and member_kind not in {'variable', 'typedef', 'enumeration', "enumvalue"}:
- function_list.append((member_symbol, arglist_text, member_kind, join(anchorfile, '#', member.findtext('anchor'))))
+ member_file = join(anchorfile, '#', member.findtext('anchor'))
+
+ if arglist and member_kind not in {'variable', 'typedef', 'enumeration', 'enumvalue'}:
+ try:
+ # Parse arguments to do overload resolution later
+ normalised_arglist = normalise(member_symbol + arglist)[1]
+ entries.append(
+ Entry(name=member_symbol, kind=member_kind, file=member_file, arglist=normalised_arglist))
+ except ParseException as e:
+ print(f'Skipping {member_kind} {member_symbol}{arglist}. Error reported from parser was: {e}')
else:
- # Put the simple things directly into the mapping
- mapping[member_symbol] = Entry(kind=member.get('kind'), file=join(anchorfile, '#', member.findtext('anchor')))
+ # Put the simple things directly into the list
+ entries.append(Entry(name=member_symbol, kind=member_kind, file=member_file, arglist=None))
- for member_symbol, arglist, kind, anchor_link in function_list:
- try:
- normalised_arglist = normalise(member_symbol + arglist)[1]
- except ParseException as e:
- print(f'Skipping {kind} {member_symbol}{arglist}. Error reported from parser was: {e}')
- else:
- if member_symbol not in mapping:
- mapping[member_symbol] = FunctionList()
- member_mapping = mapping[member_symbol]
- if not isinstance(member_mapping, FunctionList):
- raise RuntimeError(f"Cannot add override to non-function '{member_symbol}'")
- member_mapping.add_overload(normalised_arglist, anchor_link)
-
- return mapping
-
-
-def match_piecewise(candidates: Iterable[str], symbol: str, sep: str='::') -> set:
- """
- Match the requested symbol against the candidates.
- It is allowed to under-specify the base namespace so that ``"MyClass"`` can match ``my_namespace::MyClass``
-
- Args:
- candidates: set of possible matches for symbol
- symbol: the symbol to match against
- sep: the separator between identifier elements
-
- Returns:
- set of matches
- """
- min_length = len(symbol)
- piecewise_list = set()
- for item in candidates:
- if symbol == item[-min_length:] and item[-min_length-len(sep):-min_length] in [sep, '']:
- piecewise_list.add(item)
-
- return piecewise_list
+ return entries
def join(*args):
diff --git a/tests/test_doxylink.py b/tests/test_doxylink.py
index cf21a9d..a0524e4 100644
--- a/tests/test_doxylink.py
+++ b/tests/test_doxylink.py
@@ -92,13 +92,19 @@
tag_file = ET.parse(examples_tag_file)
mapping = doxylink.parse_tag_file(tag_file)
- assert 'my_lib.h' in mapping
- assert 'my_lib.h::my_func' in mapping
- assert 'my_namespace' in mapping
- assert 'my_namespace::MyClass' in mapping
- assert 'my_lib.h::MY_MACRO' in mapping
- assert 'my_namespace::MyClass::my_method' in mapping
- assert 'ClassesGroup' in mapping
+ def has_entry(name):
+ """
+ Checks if we have at least one entry with the specified name
+ """
+ return any(filter(lambda entry: entry.name == name, mapping))
+
+ assert has_entry('my_lib.h')
+ assert has_entry('my_lib.h::my_func')
+ assert has_entry('my_namespace')
+ assert has_entry('my_namespace::MyClass')
+ assert has_entry('my_lib.h::MY_MACRO')
+ assert has_entry('my_namespace::MyClass::my_method')
+ assert has_entry('ClassesGroup')
@pytest.mark.parametrize('symbol, expected_matches', [
@@ -115,11 +121,14 @@
])
def test_find_url_piecewise(examples_tag_file, symbol, expected_matches):
tag_file = ET.parse(examples_tag_file)
- mapping = doxylink.parse_tag_file(tag_file)
+ mapping = doxylink.SymbolMap(tag_file)
- matches = doxylink.match_piecewise(mapping.keys(), symbol)
- assert expected_matches == matches
- assert matches.issubset(mapping.keys())
+ matches = mapping._find_entries(symbol, None, None)
+ matched_names = {entry.name for entry in matches}
+
+ assert expected_matches == matched_names
+ assert set(matches).issubset(set(mapping._entries))
+
@pytest.mark.parametrize('str_to_validate, expected', [
('http://example.com', True),