Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ tests/test_docs/_build/
_build
htmlcov
.coverage
.coverage.*
coverage.xml
build/
104 changes: 77 additions & 27 deletions sphinxcontrib/mat_documenters.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
mat_ext_sig_re = re.compile(
r"""^ ([+@]?[+@\w.]+::)? # explicit module name
([+@]?[+@\w.]+\.)? # module and/or class name(s)
([+@]?\w+) \s* # thing name
(\.|[+@]?\w+) \s* # thing name or dot for global namespace
(?: \((.*)\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
)? $ # and nothing more
Expand Down Expand Up @@ -101,17 +101,28 @@ def parse_name(self):
)
return False

# support explicit module and class name separation via ::
if explicit_modname is not None:
modname = explicit_modname[:-2]
parents = (path and path.rstrip(".").split(".")) or []
# Handle special case where base is "." (global namespace)
# This only applies when there's no explicit module name and no path
if base == "." and explicit_modname is None and path is None:
logger.info(
"[sphinxcontrib-matlabdomain] parse_name: "
"Special case for root module detected"
)
# "." means document the global namespace (root module)
self.modname = ""
self.objpath = []
else:
modname = None
parents = []
# support explicit module and class name separation via ::
if explicit_modname is not None:
modname = explicit_modname[:-2]
parents = (path and path.rstrip(".").split(".")) or []
else:
modname = None
parents = []

self.modname, self.objpath = self.resolve_name(modname, parents, path, base)
self.modname, self.objpath = self.resolve_name(modname, parents, path, base)

if not self.modname:
if self.modname is None:
return False

self.args = args
Expand All @@ -136,6 +147,21 @@ def import_object(self):
f"{self.objpath=}, {self.fullname=}."
)
logger.debug(msg)

# Handle special case: documenting the root/global namespace
if self.modname == "" and len(self.objpath) == 0:
# Look up the root module in entities_table
root_obj = entities_table.get(".")
logger.info(
"[sphinxcontrib-matlabdomain] import_object: "
f"Found root module: {root_obj is not None}, "
f"type: {type(root_obj).__name__ if root_obj else 'None'}"
)
self.object = root_obj
if self.object is None:
raise KeyError("Root module '.' not found in entities_table")
return True

if len(self.objpath) > 1:
lookup_name = ".".join([self.modname, self.objpath[0]])
lookup_name = lookup_name.lstrip(".")
Expand Down Expand Up @@ -684,7 +710,12 @@ def document_members(self, all_members=False):
classes.sort(key=lambda cls: cls.priority)
# give explicitly separated module name, so that members
# of inner classes can be documented
full_mname = f"{self.modname}::" + ".".join([*self.objpath, mname])
# Special handling for root module (empty modname)
if self.modname == "" and len(self.objpath) == 0:
# For root module members, just use the member name
full_mname = mname
else:
full_mname = f"{self.modname}::" + ".".join([*self.objpath, mname])
documenter = classes[-1](self.directive, full_mname, self.indent)
memberdocumenters.append((documenter, isattr))
member_order = self.options.member_order or self.env.config.autodoc_member_order
Expand Down Expand Up @@ -751,22 +782,29 @@ def generate(
self.real_modname = real_modname or self.get_real_modname()

# try to also get a source code analyzer for attribute docs
try:
self.analyzer = MatModuleAnalyzer.for_module(self.real_modname)
# parse right now, to get PycodeErrors on parsing (results will
# be cached anyway)
self.analyzer.find_attr_docs()
except PycodeError as err:
self.env.app.debug(
"[sphinxcontrib-matlabdomain] module analyzer failed: %s", err
)
# no source file -- e.g. for builtin and C modules
# For the root module (empty modname), skip the analyzer
if self.real_modname:
try:
self.analyzer = MatModuleAnalyzer.for_module(self.real_modname)
# parse right now, to get PycodeErrors on parsing (results will
# be cached anyway)
self.analyzer.find_attr_docs()
except PycodeError as err:
self.env.app.debug(
"[sphinxcontrib-matlabdomain] module analyzer failed: %s", err
)
# no source file -- e.g. for builtin and C modules
self.analyzer = None
# at least add the module.__file__ as a dependency
if hasattr(self.module, "__file__") and self.module.__file__:
self.directive.record_dependencies.add(self.module.__file__)
else:
self.directive.record_dependencies.add(self.analyzer.srcname)
else:
# Root module has no real module name, so no analyzer needed
self.analyzer = None
# at least add the module.__file__ as a dependency
if hasattr(self.module, "__file__") and self.module.__file__:
self.directive.record_dependencies.add(self.module.__file__)
else:
self.directive.record_dependencies.add(self.analyzer.srcname)

# check __module__ of object (for members not given explicitly)
if check_module and not self.check_module():
Expand Down Expand Up @@ -808,7 +846,14 @@ def parse_name(self):
return ret

def add_directive_header(self, sig):
MatlabDocumenter.add_directive_header(self, sig)
# Special case for root module (empty modname and objpath)
if self.modname == "" and len(self.objpath) == 0:
# Don't add the directive header for root module
# Just add the signature if there is one
if sig:
self.add_line(sig, self.get_sourcename())
else:
MatlabDocumenter.add_directive_header(self, sig)

# add some module-specific options
if self.options.synopsis:
Expand All @@ -823,7 +868,8 @@ def get_object_members(self, want_all):
if not hasattr(self.object, "__all__"):
# for implicit module members, check __module__ to avoid
# documenting imported objects
return True, self.object.safe_getmembers()
members = self.object.safe_getmembers()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to declare the local variable here.

return True, members
else:
memberlist = [name for name, _ in self.object.__all__]
else:
Expand Down Expand Up @@ -867,7 +913,9 @@ def resolve_name(self, modname, parents, path, base):
# ... or in the scope of a module directive
if not modname:
modname = self.env.temp_data.get("mat:module")
# ... else, it stays None, which means invalid
# ... else, default to root module (empty string)
if not modname:
modname = ""
return modname, [*parents, base]


Expand Down Expand Up @@ -899,7 +947,9 @@ def resolve_name(self, modname, parents, path, base):
modname = self.env.temp_data.get("autodoc:module")
if not modname:
modname = self.env.temp_data.get("mat:module")
# ... else, it stays None, which means invalid
# ... else, default to root module (empty string)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is this always a sane default? I suspect that this can lead to subtle bugs where things that were considered invalid before will now get dumbed in the "root" module.

if not modname:
modname = ""
return modname, [*parents, base]


Expand Down
25 changes: 25 additions & 0 deletions sphinxcontrib/mat_tree_sitter_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
from importlib.metadata import version

import tree_sitter_matlab as tsml
from sphinx.util.logging import getLogger
from tree_sitter import Language

logger = getLogger("matlab-domain")

# Attribute default dictionary used to give default values
# for e.g. `Abstract` or `Static` when used without
# a right hand side i.e. `classdef (Abstract)` vs `classdef (Abstract=true)`
Expand Down Expand Up @@ -370,6 +373,10 @@ def _parse_argument_section(self, argblock_node):

arguments = argblock_match.get("args")

# Bug fix: Check if arguments is None before iterating
if arguments is None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be necessary? The * operator in q_argblock should return the empty list for the args capture name.

Either way this shouldn't hurt to add but would be good to add a test for this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also probably no need to have Bug fix in the comment, as it only adds semantic noise.

return

# TODO this is almost identical to property parsing.
# might be a good idea to extract common code here.
for arg in arguments:
Expand Down Expand Up @@ -557,6 +564,24 @@ def __init__(self, root_node, encoding):

# Parse class basics
class_matches = q_classdef.matches(root_node)

# Bug fix: Handle empty matches gracefully
if not class_matches:
logger.warning(
"[sphinxcontrib-matlabdomain] "
"No class definition found in file, skipping"
)
# Set minimal attributes to avoid crashes
self.cls = None
self.name = None
self.attrs = {}
self.supers = []
self.docstring = ""
self.properties = {}
self.methods = {}
self.enumerations = {}
return

_, class_match = class_matches[0]
self.cls = class_match.get("class")
self.name = class_match.get("name")
Expand Down
87 changes: 64 additions & 23 deletions sphinxcontrib/mat_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ def classfolder_class_name(dotted_path):

def recursive_find_all(obj):
# Recursively finds all entities in all "modules" aka directories.
# Bug fix: Check if obj.entities exists and is not None
if getattr(obj, "entities", None) is None:
return

for _, o in obj.entities:
if isinstance(o, MatModule):
o.safe_getmembers()
Expand All @@ -163,6 +167,10 @@ def recursive_find_all(obj):

def recursive_log_debug(obj, indent=""):
# Traverse the object hierarchy and log to debug
# Bug fix: Check if obj.entities exists and is not None
if getattr(obj, "entities", None) is None:
return

for n, o in obj.entities:
logger.debug(
"[sphinxcontrib-matlabdomain] %s Name=%s, Entity=%s", indent, n, str(o)
Expand All @@ -184,6 +192,9 @@ def recursive_log_debug(obj, indent=""):

def populate_entities_table(obj, path=""):
# Recursively scan the hiearachy of entities and populate the entities_table.
if getattr(obj, "entities", None) is None:
return

for _n, o in obj.entities:
fullpath = f"{path}.{o.name}"
fullpath = fullpath.lstrip(".")
Expand All @@ -205,36 +216,62 @@ def analyze(app):
# `matlab_src_dir` is recursively scanned for MATLAB objects only once.
# All entities found are stored in globally available `entities_table`

if app.env.config.matlab_src_dir is None:
try:
if app.env.config.matlab_src_dir is None:
logger.debug(
"[sphinxcontrib-matlabdomain] matlab_src_dir is None, skipping parsing."
)
return

# Interpret `matlab_src_dir` relative to the sphinx source directory.
basedir = os.path.normpath(
os.path.join(app.env.srcdir, app.env.config.matlab_src_dir)
)
MatObject.basedir = basedir # set MatObject base directory
MatObject.sphinx_env = app.env # pass env to MatObject cls
MatObject.sphinx_app = app # pass app to MatObject cls

entities_table.clear()
entities_name_map.clear()

# Set the root object and get root members.
logger.debug("[sphinxcontrib-matlabdomain] Starting matlabify")
root = MatObject.matlabify("")
if not root:
logger.debug("[sphinxcontrib-matlabdomain] root is None, returning")
return

logger.debug(
"[sphinxcontrib-matlabdomain] matlab_src_dir is None, skipping parsing."
f"[sphinxcontrib-matlabdomain] root={root}, "
f"root.entities={getattr(root, 'entities', 'NO ENTITIES ATTR')}"
)
root.safe_getmembers()
logger.debug(
"[sphinxcontrib-matlabdomain] After safe_getmembers, "
f"root.entities={getattr(root, 'entities', 'NO ENTITIES ATTR')}"
)
return

# Interpret `matlab_src_dir` relative to the sphinx source directory.
basedir = os.path.normpath(
os.path.join(app.env.srcdir, app.env.config.matlab_src_dir)
)
MatObject.basedir = basedir # set MatObject base directory
MatObject.sphinx_env = app.env # pass env to MatObject cls
MatObject.sphinx_app = app # pass app to MatObject cls
logger.debug("[sphinxcontrib-matlabdomain] Starting recursive_find_all")
recursive_find_all(root)
logger.debug("[sphinxcontrib-matlabdomain] Finished recursive_find_all")

entities_table.clear()
entities_name_map.clear()
# Print the hierarchy of entities to the log.
logger.debug("[sphinxcontrib-matlabdomain] Found the following entities:")
recursive_log_debug(root)

# Set the root object and get root members.
root = MatObject.matlabify("")
if not root:
return
root.safe_getmembers()
recursive_find_all(root)
logger.debug("[sphinxcontrib-matlabdomain] Starting populate_entities_table")
populate_entities_table(root)
logger.debug("[sphinxcontrib-matlabdomain] Finished populate_entities_table")
entities_table["."] = root

# Print the hierarchy of entities to the log.
logger.debug("[sphinxcontrib-matlabdomain] Found the following entities:")
recursive_log_debug(root)
except Exception as e:
import traceback

populate_entities_table(root)
entities_table["."] = root
logger.error(f"[sphinxcontrib-matlabdomain] ERROR in analyze: {e}")
logger.error(
f"[sphinxcontrib-matlabdomain] Traceback: {traceback.format_exc()}"
)
raise

"""
Transform Class Folders classes from
Expand All @@ -261,6 +298,10 @@ def isClassFolderModule(name, entity):
}
# For each Class Folder module
for cf_entity in class_folder_modules.values():
# Bug fix: Check if cf_entity has entities and they're not None
if not hasattr(cf_entity, "entities") or cf_entity.entities is None:
continue

# Find the class entity class.
class_entities = [e for e in cf_entity.entities if isinstance(e[1], MatClass)]
func_entities = [e for e in cf_entity.entities if isinstance(e[1], MatFunction)]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
classdef ClassWithSeperatedComments
classdef ClassWithSeparatedComments
properties
% some comment

Expand Down
10 changes: 10 additions & 0 deletions tests/test_data/f_with_output_repeating_argument_block.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
function varargout = f_with_output_repeating_argument_block(a1)
arguments (Input)
a1 (1,1) double {mustBePositive} % Positive scalar input
end
arguments (Output,Repeating)
varargout (1,1) double {mustBePositive} % Repeating outputs
end

varargout = {a1, a1 + 1};
end
14 changes: 14 additions & 0 deletions tests/test_data/f_with_repeating_argument_block.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
function s = f_with_repeating_argument_block(a, varargin)
arguments (Input)
a (1,1) double {mustBePositive} % Positive scalar input
end
arguments (Repeating)
varargin (1,1) double {mustBePositive} % Repeating positive scalar
end

arguments (Output)
s (1,1) double
end

s = sum([a, varargin{:}]);
end
Loading
Loading