Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option matlab_auto_link #183

Merged
merged 31 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
284e89d
Add linking of classes and functions in See also lines.
rdzman Apr 28, 2023
a1f4cdd
Add option `matlab_auto_link`
rdzman Apr 28, 2023
759b0f6
Add ref_role() method to MatObject
rdzman Apr 28, 2023
05e5cf2
Add option to auto-link everywhere.
rdzman Apr 28, 2023
735fc34
Exclude auto-linking class name in "<class> Properties:" or "<class> …
rdzman Apr 28, 2023
f2e90dc
Fix issue with matching things that are not whole words.
rdzman Apr 28, 2023
96c0697
Allow case-insensitive match of "See Also" w or w/o trailing ":"
rdzman May 1, 2023
99a59ba
Add tests for matlab_auto_link = "see_also"
rdzman May 1, 2023
2cb1bed
Add code to force matlab_short_links to True if matlab_auto_link is n…
rdzman May 4, 2023
b954cb1
Fix issue with broken auto-links if matlab_short_links is False
rdzman May 4, 2023
8677d41
Revert "Add code to force matlab_short_links to True if matlab_auto_l…
rdzman May 4, 2023
b077191
Refactor "see also" auto-linking.
rdzman May 12, 2023
cb0c2f6
Update tests to include MATLAB names in See also lines.
rdzman May 17, 2023
be95645
Add test of double-backquotes around unknown entities in see also sec…
rdzman May 17, 2023
b358863
Create entities_name_map only once at the same time as entities_table.
rdzman May 18, 2023
feb7594
Modify logic so that matlab_auto_link = "all" handles see_also line f…
rdzman May 18, 2023
8fb4b39
Refactor MatlabDocumenter.auto_link() into separate methods
rdzman May 23, 2023
7d2254b
Refactor make_baseclass_links() into 2 new MatClass methods
rdzman May 23, 2023
b21d7b4
Change "see_also" value of matlab_auto_link to "basic".
rdzman May 23, 2023
5581ef7
Add auto-linking of property and method names in class docstring.
rdzman May 23, 2023
715ae27
Remove leading . in MatClass.fullname() results
rdzman May 24, 2023
73526a3
Fix issue with matlab_auto_link = "all" trying to link class names wi…
rdzman May 24, 2023
721ae12
Update tests for matlab_auto_link = "basic" to include property/metho…
rdzman May 24, 2023
35aff05
Add a test for matlab_auto_link = "all"
rdzman May 24, 2023
52591d5
Update README.rst for latest auto-link behavior.
rdzman May 24, 2023
155e85d
Add parens following the name of a linked method.
rdzman May 26, 2023
b228175
Add auto-linking of method names in class/method/property docstrings.
rdzman May 26, 2023
308c9a1
Add autodoc tests for matlab_short_links = True.
rdzman May 26, 2023
0dd5514
Fix bug in class links when matlab_short_links = True
rdzman May 26, 2023
bf48ec8
Fix class links so names are consistent with targets.
rdzman May 26, 2023
fcb1f0c
Skip over literal blocks when auto-linking with matlab_auto_link = "all"
rdzman May 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ Additional Configuration
If you want the closest to MATLAB documentation style, use ``matlab_short_links
= True`` in your ``conf.py`` file.

``matlab_auto_link``
Automatically convert the names of known entities (e.g. classes, functions,
properties, methods) to links Valid values are ``"basic"``
and ``"all"``.
* ``"basic"`` - Auto-links (1) known classes or functions that appear
in docstring lines that begin with "See also" and any subsequent
lines before the next blank line (unknown names are wrapped in
double-backquotes), and (2) property and method names that appear in
lists under "<MyClass> Properties:" and "<MyClass> Methods:" headings
in class docstrings.

* ``"all"`` - Auto-links everything included with ``"basic"``, plus all
known classes and functions everywhere else they appear in any docstring,
and any names ending with "()" within class, property, or method docstrings
that match a method of the corresponding class.

Default is ``None``. *Added in Version 0.20.0*.


Roles and Directives
--------------------
Expand Down
202 changes: 187 additions & 15 deletions sphinxcontrib/mat_documenters.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
MatModuleAnalyzer,
MatApplication,
entities_table,
entities_name_map,
strip_package_prefix,
)

Expand Down Expand Up @@ -171,6 +172,8 @@ def add_content(self, more_content, no_docstring=False):
# autodoc-process-docstring is fired and can add some
# content if desired
docstrings.append([])
if self.env.config.matlab_auto_link:
docstrings = self.auto_link(docstrings)
for i, line in enumerate(self.process_doc(docstrings)):
self.add_line(line, sourcename, i)

Expand All @@ -179,6 +182,129 @@ def add_content(self, more_content, no_docstring=False):
for line, src in zip(more_content.data, more_content.items):
self.add_line(line, src[0], src[1])

def auto_link_basic(self, docstrings):
return self.auto_link_see_also(docstrings)

def auto_link_see_also(self, docstrings):
# autolink known names in See also
see_also_re = re.compile(r"(See also:?\s*)(\b.*\b)(.*)", re.IGNORECASE)
see_also_cond_re = re.compile(r"(\s*)(\b.*\b)(.*)")
is_see_also_line = False
for i in range(len(docstrings)):
for j in range(len(docstrings[i])):
line = docstrings[i][j]
if line: # non-blank line
if is_see_also_line:
# find name
match = see_also_cond_re.search(line)
entries_str = match.group(2) # the entries
elif match := see_also_re.search(line):
is_see_also_line = True # line begins with "See also"
entries_str = match.group(2) # the entries
elif is_see_also_line: # blank line following see also section
is_see_also_line = False # end see also section

if is_see_also_line and entries_str:
# split on ,
entries = re.split(r"\s*,\s*", entries_str)
for k in range(len(entries)):
if entries[k].endswith("`"):
continue

if (
self.env.config.matlab_keep_package_prefix
and entries[k] in entities_table
):
o = entities_table[entries[k]]
elif (
not self.env.config.matlab_keep_package_prefix
and entries[k] in entities_name_map
):
o = entities_table[entities_name_map[entries[k]]]
else:
o = None
if o:
role = o.ref_role()
if role in ["class", "func"]:
entries[k] = f":{role}:`{entries[k]}`"
else:
entries[k] = f"``{entries[k]}``"
docstrings[i][j] = (
match.group(1) + ", ".join(entries) + match.group(3)
)
return docstrings

def auto_link_all(self, docstrings):
# auto-link known classes and functions everywhere
for n, o in entities_table.items():
role = o.ref_role()
if role in ["class", "func"]:
nn = n.replace("+", "") # remove + from name
pat = (
r"(?<!(`|\.|\+|<))\b" # negative look-behind for ` or . or + or <
+ nn.replace(".", "\.") # escape .
+ r"\b(?!(`|\sProperties|\sMethods):)" # negative look-ahead for ` or " Properties:" or " Methods:"
)
p = re.compile(pat)
no_link = 0 # normal mode (no literal block detected)
for i in range(len(docstrings)):
for j in range(len(docstrings[i])):
# skip over literal blocks (i.e. line ending with ::, blank line, indented line)
if docstrings[i][j].endswith("::"):
no_link = -1 # 1st sign of literal block
elif not docstrings[i][j]: # blank line
if no_link == -1: # if 1st sign already detected
no_link = -2 # 2nd sign of literal block
elif no_link == 1: # if in literal block
no_link = 0 # end the literal block, restart linking
elif no_link == -2 and docstrings[i][j].startswith(" "):
# indented line after 1st 2 signs
no_link = 1 # beginning of literal block (stop linking!)
elif no_link != 1: # not in a literal block, go ahead and link
docstrings[i][j] = p.sub(
f":{role}:`{nn}`", docstrings[i][j]
)

return docstrings

def auto_link(self, docstrings):
# basic auto-linking
if self.env.config.matlab_auto_link: # "basic" or "all" (i.e. not None)
docstrings = self.auto_link_basic(docstrings)

# auto-link everywhere
if self.env.config.matlab_auto_link == "all":
docstrings = self.auto_link_all(docstrings)

return docstrings

def auto_link_methods(self, class_obj, docstrings):
for n, o in class_obj.methods.items():
# negative look-behind for ` or . or <, then <name>()
pat = r"(?<!(`|\.|<))\b" + n + r"\(\)"
p = re.compile(pat)
no_link = 0 # normal mode (no literal block detected)
for i in range(len(docstrings)):
for j in range(len(docstrings[i])):
# skip over literal blocks (i.e. line ending with ::, blank line, indented line)
if docstrings[i][j].endswith("::"):
no_link = -1 # 1st sign of literal block
elif not docstrings[i][j]: # blank line
if no_link == -1: # if 1st sign already detected
no_link = -2 # 2nd sign of literal block
elif no_link == 1: # if in literal block
no_link = 0 # end the literal block, start linking again
elif no_link == -2 and docstrings[i][j].startswith(" "):
# indented line after 1st 2 signs
no_link = 1 # beginning of literal block (stop linking!)
elif no_link != 1: # not in a literal block, go ahead and link
docstrings[i][j] = p.sub(
f":meth:`{n}() <{class_obj.fullname(self.env)}.{n}>`",
docstrings[i][j],
)

return docstrings

def get_object_members(self, want_all):
"""Return `(members_check_module, members)` where `members` is a
list of `(membername, member)` pairs of the members of *self.object*.
Expand Down Expand Up @@ -776,21 +902,7 @@ def make_baseclass_links(env, obj):
if not entity:
links.append(":class:`%s`" % base_class_name)
else:
modname = entity.__module__
classname = entity.name
if not env.config.matlab_keep_package_prefix:
modname = strip_package_prefix(modname)

if env.config.matlab_short_links:
# modname is only used for package names
# - "target.+package" => "package"
# - "target" => ""
parts = modname.split(".")
parts = [part for part in parts if part.startswith("+")]
modname = ".".join(parts)

link_name = f"{modname}.{classname}"
links.append(f":class:`{base_class_name}<{link_name}>`")
links.append(entity.link(env))

return links

Expand Down Expand Up @@ -926,6 +1038,58 @@ def add_content(self, more_content, no_docstring=False):
else:
MatModuleLevelDocumenter.add_content(self, more_content)

def auto_link_basic(self, docstrings):
docstrings = MatlabDocumenter.auto_link_basic(self, docstrings)
return self.auto_link_class_members(docstrings)

def auto_link_class_members(self, docstrings):
# auto link property and method names in class docstring
prop_re = re.compile(r"(.* Properties:)", re.IGNORECASE)
meth_re = re.compile(r"(.* Methods:)", re.IGNORECASE)
is_prop_line = False
is_meth_line = False
for i in range(len(docstrings)):
for j in range(len(docstrings[i])):
line = docstrings[i][j]
if line: # non-blank line
if prop_re.search(line): # line ends with "Properties:"
is_prop_line = True
is_meth_line = False
elif meth_re.search(line): # line ends with "Methods:"
is_prop_line = False
is_meth_line = True
elif is_prop_line:
# auto-link first word to corresponding property, if it exists
docstrings[i][j] = self.link_member("attr", line)
elif is_meth_line:
# auto-link first word to corresponding method, if it exists
docstrings[i][j] = self.link_member("meth", line)
elif is_prop_line: # blank line following properties section
is_prop_line = False # end properties section
elif is_meth_line: # blank line following methods section
is_meth_line = False # end methods section

return docstrings

def link_member(self, type, line):
if type == "meth":
parens = "()"
else:
parens = ""
p = re.compile(r"((\*\s*)?(\b\w*\b))(?=\s*-)")
if match := p.search(line):
name = match.group(3)
line = p.sub(
f"* :{type}:`{name}{parens} <{self.object.fullname(self.env)}.{name}>`",
line,
1,
)
return line

def auto_link_all(self, docstrings):
docstrings = MatlabDocumenter.auto_link_all(self, docstrings)
return self.auto_link_methods(self.object, docstrings)

def document_members(self, all_members=False):
if self.doc_as_attr:
return
Expand Down Expand Up @@ -1087,6 +1251,10 @@ def format_args(self):
def document_members(self, all_members=False):
pass

def auto_link_all(self, docstrings):
docstrings = MatlabDocumenter.auto_link_all(self, docstrings)
return self.auto_link_methods(self.object.cls, docstrings)


class MatAttributeDocumenter(MatClassLevelDocumenter):
"""
Expand Down Expand Up @@ -1157,6 +1325,10 @@ def add_content(self, more_content, no_docstring=False):
# no_docstring = True
MatClassLevelDocumenter.add_content(self, more_content, no_docstring)

def auto_link_all(self, docstrings):
docstrings = MatlabDocumenter.auto_link_all(self, docstrings)
return self.auto_link_methods(self.object.cls, docstrings)


class MatInstanceAttributeDocumenter(MatAttributeDocumenter):
"""
Expand Down
57 changes: 57 additions & 0 deletions sphinxcontrib/mat_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@
# Will result in a short name of: package.ClassBar
entities_table = {}

# Dictionary containing a map of names WITHOUT '+' in package names to
# the corresponding names WITH '+' in the package name. This is only
# used if "matlab_auto_link" is on AND "matlab_keep_package_prefix"
# is True AND a docstring with "see also" is encountered.
entities_name_map = {}


def shortest_name(dotted_path):
# Creates the shortest valid MATLAB name from a dotted path
Expand Down Expand Up @@ -108,6 +114,7 @@ def populate_entities_table(obj, path=""):
fullpath = path + "." + o.name
fullpath = fullpath.lstrip(".")
entities_table[fullpath] = o
entities_name_map[strip_package_prefix(fullpath)] = fullpath
if isinstance(o, MatModule):
if o.entities:
populate_entities_table(o, fullpath)
Expand Down Expand Up @@ -152,6 +159,7 @@ def analyze(app):
short_name = shortest_name(name)
if short_name != name:
short_names[short_name] = entity
entities_name_map[short_name] = short_name

entities_table.update(short_names)

Expand Down Expand Up @@ -191,6 +199,10 @@ def __init__(self, name):
#: name of MATLAB object
self.name = name

def ref_role(self):
"""Returns role to use for references to this object (e.g. when generating auto-links)"""
return "ref"

@property
def __name__(self):
return self.name
Expand Down Expand Up @@ -430,6 +442,10 @@ def __init__(self, name, path, package):
#: entities found in the module: class, function, module (subpath and +package)
self.entities = []

def ref_role(self):
"""Returns role to use for references to this object (e.g. when generating auto-links)"""
return "mod"

def safe_getmembers(self):
logger.debug(
f"[sphinxcontrib-matlabdomain] MatModule.safe_getmembers {self.name=}, {self.path=}, {self.package=}"
Expand Down Expand Up @@ -842,6 +858,10 @@ def __init__(self, name, modname, tokens):
if len(tks) > 0:
self.rem_tks = tks # save extra tokens

def ref_role(self):
"""Returns role to use for references to this object (e.g. when generating auto-links)"""
return "func"

@property
def __doc__(self):
return self.docstring
Expand Down Expand Up @@ -1306,6 +1326,35 @@ def __init__(self, name, modname, tokens):

self.rem_tks = idx # index of last token

def ref_role(self):
"""Returns role to use for references to this object (e.g. when generating auto-links)"""
return "class"

def fullname(self, env):
"""Returns full name for class object, for use as link target"""
modname = self.__module__
classname = self.name
if env.config.matlab_short_links:
# modname is only used for package names
# - "target.+package" => "package"
# - "target" => ""
parts = modname.split(".")
parts = [part for part in parts if part.startswith("+")]
modname = ".".join(parts)

if not env.config.matlab_keep_package_prefix:
modname = strip_package_prefix(modname)

return f"{modname}.{classname}".lstrip(".")

def link(self, env, name=None):
"""Returns link for class object"""
target = self.fullname(env)
if name:
return f":class:`{name} <{target}>`"
else:
return f":class:`{target}`"

def attributes(self, idx, attr_types):
"""
Retrieve MATLAB class, property and method attributes.
Expand Down Expand Up @@ -1460,6 +1509,10 @@ def __init__(self, name, cls, attrs):
self.docstring = attrs["docstring"]
# self.class = attrs['class']

def ref_role(self):
"""Returns role to use for references to this object (e.g. when generating auto-links)"""
return "attr"

@property
def __doc__(self):
return self.docstring
Expand All @@ -1472,6 +1525,10 @@ def __init__(self, modname, tks, cls, attrs):
self.cls = cls
self.attrs = attrs

def ref_role(self):
"""Returns role to use for references to this object (e.g. when generating auto-links)"""
return "meth"

def skip_tokens(self):
# Number of tokens to skip in `MatClass`
num_rem_tks = len(self.rem_tks)
Expand Down
Loading