Skip to content

Commit 81b551d

Browse files
authored
feat: folder modules (#37)
* remove redundant root object * feat: document folders * uv format * docs: update readme
1 parent 56e8f87 commit 81b551d

File tree

7 files changed

+166
-72
lines changed

7 files changed

+166
-72
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
[![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://watermarkhu.nl/mkdocstrings-matlab)
1111
[![pypi version](https://img.shields.io/pypi/v/mkdocstrings-matlab.svg)](https://pypi.org/project/mkdocstrings-matlab/)
1212

13-
The MATLAB handler uses [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and its [MATLAB parser](https://github.com/acristoffers/tree-sitter-matlab) to collect documentation from MATLAB source code. Via the python bindings the Abstract Syntax Tree (AST) of the source code is traversed to extract useful information. The imported objected are imported as custom [Griffe](https://mkdocstrings.github.io/griffe/) objects and mocked for the [python handler](https://mkdocstrings.github.io/python/).
13+
The MATLAB handler uses [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and its [MATLAB parser](https://github.com/acristoffers/tree-sitter-matlab) to collect documentation from MATLAB source code. The AST information are imported as custom [Griffe](https://mkdocstrings.github.io/griffe/) objects and mocked for the [python handler](https://mkdocstrings.github.io/python/).
1414

1515

1616
You can install this handler by specifying it as a dependency:
@@ -34,10 +34,10 @@ dependencies = [
3434

3535
- **Support for argument validation blocks:** Tree-sitter collects your [function and method argument validation](https://mathworks.com/help/matlab/matlab_prog/function-argument-validation-1.html)
3636
blocks to display input and output argument types and default values.
37-
It is even able to automatically add cross-references o other objects from your API.
37+
It is even able to automatically add cross-references to other objects from your API.
3838

39-
- **Recursive documentation of MATLAB [namespaces](https://mathworks.com/help/matlab/matlab_oop/namespaces.html):**
40-
just add `+` to the identifer, and you get the full namespace docs. You don't need to inject documentation for each class, function, and script. Additionaly, the parent namespace documentation will be either extracted from the `Contents.m` or the `readme.md` file at the root of the namespace.
39+
- **Recursive documentation of MATLAB [namespaces](https://mathworks.com/help/matlab/matlab_oop/namespaces.html) and folders:**
40+
just add `+` to the identifer for namespaces or the relative path for folder, and you get documentation for the entire directory. You don't need to inject documentation for each class, function, and script. Additionaly, the directory documentation will be either extracted from the `Contents.m` or the `readme.md` file at the root of the namespace or folder.
4141

4242
- **Support for documented properties:** properties definitions followed by a docstring will be recognized in classes.
4343

docs/index.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ Given the function above, the rendered documentation here is created from the fo
2727
parse_arguments: true
2828
```
2929

30-
31-
3230
</div>
3331

3432
--8<-- "README.md:footer"

docs/usage/index.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ If another handler was defined as default handler, you can explicitely ask for t
5656
::: path.to.object
5757
handler: matlab
5858
```
59+
### Namespaces
5960

6061
Entire [namespaces](https://mathworks.com/help/matlab/matlab_oop/namespaces.html) can be fully documented by prefixing the `+` character to the namespace that is to be documented. E.g. the following namespace
6162

@@ -74,15 +75,57 @@ is documented with:
7475
::: +mynamespace
7576
```
7677

77-
The docstring of the namespace is taken from either the [`Contents.m`](https://mathworks.com/help/matlab/matlab_prog/create-a-help-summary-contents-m.html) or a `readme.md` that resides at the root level of the namespace, with `Contents.m` taking precedence over `readme.md`.
78-
78+
The docstring of the namespace is taken from either the [`Contents.m`](https://mathworks.com/help/matlab/matlab_prog/create-a-help-summary-contents-m.html) or a `readme.md` that resides at the root level of the namespace, with `Contents.m` taking precedence over `readme.md`.
7979

8080
Documenting a nested namespace requires only a single prefixed `+` at the start of the fully resolved path, e.g.
8181

8282
```md
8383
::: +mynamespace.subnamespace
8484
```
8585

86+
### Folders
87+
88+
Similarly to namepaces, all contents of a folder can be fully documented by specifying the relative path of a folder with respect to the `mkdocs.yml` config file. E.g. the following repository
89+
90+
```tree
91+
src
92+
module
93+
myfunction.m
94+
myClass.m
95+
submodule
96+
myfunction.m
97+
+mynamespace
98+
namespacefunction.m
99+
docs
100+
index.md
101+
mkdocs.yml
102+
```
103+
104+
is documented with:
105+
106+
```markdown
107+
::: src/module
108+
```
109+
110+
In the case above the function `module/submodule/myfunction.m` overshadows the function `module/myfunction.m` on the MATLAB path. This means that in the global namespace myfunction will always call `module/submodule/myfunction.m`, which is the function to be documented by `::: myfunction`.
111+
112+
While this kind of behavior is strictly recommended against, mkdocstrings-matlab does support documenting the shadowed function by using its path. The file extension is now stricty required.
113+
114+
```markdown
115+
::: src/module/myfunction.m
116+
```
117+
118+
!!! tip
119+
120+
A folder identifier must strictly contain the `/` character. For a folder `foo` that is in the same directory with `mkdocs.yml`, use `::: ./foo`.
121+
122+
!!! tip
123+
124+
If the `mkdocs.yml` lives inside of a subdirectly that does not contain source code, use relative paths e.g. `../src/module`.
125+
126+
!!! tip
127+
128+
Sub-selecting folder members are possible with the [members](./configuration/members.md) options.
86129

87130
### Global-only options
88131

src/mkdocstrings_handlers/matlab/collect.py

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections import defaultdict, deque
44
from copy import copy, deepcopy
55
from pathlib import Path
6-
from typing import Mapping, Sequence
6+
from typing import Mapping, Sequence, Callable, TypeVar
77

88
from _griffe.collections import LinesCollection as GLC, ModulesCollection
99
from _griffe.docstrings.models import (
@@ -24,14 +24,16 @@
2424
Docstring,
2525
DocstringSectionText,
2626
Function,
27+
Folder,
2728
MatlabMixin,
28-
Object,
2929
Namespace,
30-
ROOT,
30+
PathMixin,
3131
)
3232
from mkdocstrings_handlers.matlab.treesitter import FileParser
3333

3434

35+
PathType = TypeVar("PathType", bound=PathMixin)
36+
3537
__all__ = ["LinesCollection", "PathCollection"]
3638

3739

@@ -104,6 +106,7 @@ class PathCollection(ModulesCollection):
104106
matlab_path (Sequence[str | Path]): A list of strings or Path objects representing the MATLAB paths.
105107
recursive (bool, optional): If True, recursively adds all subdirectories of the given paths to the search path. Defaults to False.
106108
config (Mapping, optional): Configuration settings for the PathCollection. Defaults to {}.
109+
config_path (Path | None, optional): The path to the configuration file. Defaults to None.
107110
108111
Methods:
109112
members() -> dict:
@@ -130,6 +133,7 @@ def __init__(
130133
matlab_path: Sequence[str | Path],
131134
recursive: bool = False,
132135
config: Mapping = {},
136+
config_path: Path | None = None,
133137
) -> None:
134138
"""
135139
Initialize an instance of PathCollection.
@@ -148,6 +152,8 @@ def __init__(
148152
self._mapping: dict[str, deque[Path]] = defaultdict(deque)
149153
self._models: dict[Path, LazyModel] = {}
150154
self._members: dict[Path, list[tuple[str, Path]]] = defaultdict(list)
155+
self._folders: dict[str, LazyModel] = {}
156+
self._config_path = config_path
151157

152158
self.config = config
153159
self.lines_collection = LinesCollection()
@@ -188,6 +194,26 @@ def resolve(
188194
model = self._models[self._mapping[identifier][0]].model()
189195
if model is not None:
190196
model = self.update_model(model, config)
197+
198+
elif self._config_path is not None and "/" in identifier:
199+
absolute_path = (self._config_path / Path(identifier)).resolve()
200+
if absolute_path.exists():
201+
path = absolute_path.relative_to(self._config_path)
202+
if path.suffix:
203+
path, member = path.parent, path.stem
204+
else:
205+
member = None
206+
lazymodel = self._folders.get(str(path), None)
207+
208+
if lazymodel is not None:
209+
model = lazymodel.model()
210+
if model is not None and member is not None:
211+
model = model.members.get(member, None)
212+
else:
213+
model = None
214+
else:
215+
model = None
216+
191217
else:
192218
model = None
193219
name_parts = identifier.split(".")
@@ -511,13 +537,25 @@ def addpath(self, path: str | Path, to_end: bool = False, recursive: bool = Fals
511537
else:
512538
self._path.appendleft(path)
513539

514-
members = PathGlobber(path, recursive=recursive)
515-
for member in members:
540+
for member in PathGlobber(path, recursive=recursive):
516541
model = LazyModel(member, self)
517542
self._models[member] = model
518543
self._mapping[model.name].append(member)
519544
self._members[path].append((model.name, member))
520545

546+
if self._config_path is not None and member.parent.stem[0] not in [
547+
"+",
548+
"@",
549+
]:
550+
if member.parent.is_relative_to(self._config_path):
551+
relative_path = member.parent.relative_to(self._config_path)
552+
if member.parent not in self._folders:
553+
self._folders[str(relative_path)] = LazyModel(
554+
member.parent, self
555+
)
556+
else:
557+
pass # TODO: Issue warning?
558+
521559
def rm_path(self, path: str | Path, recursive: bool = False):
522560
"""
523561
Removes a path from the search path and updates the namespace and database accordingly.
@@ -610,6 +648,10 @@ def __init__(self, path: Path, path_collection: PathCollection):
610648
self._path_collection: PathCollection = path_collection
611649
self._lines_collection: LinesCollection = path_collection.lines_collection
612650

651+
@property
652+
def is_folder(self) -> bool:
653+
return self._path.is_dir() and self._path.name[0] not in ["+", "@"]
654+
613655
@property
614656
def is_class_folder(self) -> bool:
615657
return self._path.is_dir() and self._path.name[0] == "@"
@@ -648,7 +690,7 @@ def name(self):
648690
else:
649691
return name
650692

651-
def model(self):
693+
def model(self) -> MatlabMixin | None:
652694
if not self._path.exists():
653695
return None
654696

@@ -657,19 +699,22 @@ def model(self):
657699
self._model = self._collect_classfolder(self._path)
658700
elif self.is_namespace:
659701
self._model = self._collect_namespace(self._path)
702+
elif self.is_folder:
703+
self._model = self._collect_folder(self._path)
660704
else:
661705
self._model = self._collect_path(self._path)
662706
if self._model is not None:
663707
self._model.parent = self._collect_parent(self._path.parent)
664708
return self._model
665709

666-
def _collect_parent(self, path: Path) -> Object | _ParentGrabber:
710+
def _collect_parent(self, path: Path) -> _ParentGrabber | None:
667711
if self.is_in_namespace:
668-
parent = _ParentGrabber(
669-
lambda: self._path_collection._models[path].model() or ROOT
670-
)
712+
grabber: Callable[[], MatlabMixin | None] = self._path_collection._models[
713+
path
714+
].model
715+
parent = _ParentGrabber(grabber)
671716
else:
672-
parent = ROOT
717+
parent = None
673718
return parent
674719

675720
def _collect_path(self, path: Path) -> MatlabMixin:
@@ -678,6 +723,27 @@ def _collect_path(self, path: Path) -> MatlabMixin:
678723
self._lines_collection[path] = file.content.split("\n")
679724
return model
680725

726+
def _collect_directory(self, path: Path, model: PathType) -> PathType:
727+
for member in path.iterdir():
728+
if member.is_dir() and member.name[0] in ["+", "@"]:
729+
submodel = self._path_collection._models[member].model()
730+
if submodel is not None:
731+
model.members[submodel.name] = submodel
732+
733+
elif member.is_file() and member.suffix == ".m":
734+
if member.name == "Contents.m":
735+
contentsfile = self._collect_path(member)
736+
model.docstring = contentsfile.docstring
737+
else:
738+
submodel = self._path_collection._models[member].model()
739+
if submodel is not None:
740+
model.members[submodel.name] = submodel
741+
742+
if model.docstring is None:
743+
model.docstring = self._collect_readme_md(path, model)
744+
745+
return model
746+
681747
def _collect_classfolder(self, path: Path) -> Classfolder | None:
682748
classfile = path / (path.name[1:] + ".m")
683749
if not classfile.exists():
@@ -698,31 +764,17 @@ def _collect_classfolder(self, path: Path) -> Classfolder | None:
698764
model.docstring = self._collect_readme_md(path, model)
699765
return model
700766

701-
def _collect_namespace(self, path: Path) -> Namespace | None:
767+
def _collect_namespace(self, path: Path) -> Namespace:
702768
name = self.name[1:].split(".")[-1]
703769
model = Namespace(name, filepath=path, path_collection=self._path_collection)
770+
return self._collect_directory(path, model)
704771

705-
for member in path.iterdir():
706-
if member.is_dir() and member.name[0] in ["+", "@"]:
707-
submodel = self._path_collection._models[member].model()
708-
if submodel is not None:
709-
model.members[submodel.name] = submodel
710-
711-
elif member.is_file() and member.suffix == ".m":
712-
if member.name == "Contents.m":
713-
contentsfile = self._collect_path(member)
714-
model.docstring = contentsfile.docstring
715-
else:
716-
submodel = self._path_collection._models[member].model()
717-
if submodel is not None:
718-
model.members[submodel.name] = submodel
719-
720-
if model.docstring is None:
721-
model.docstring = self._collect_readme_md(path, model)
722-
723-
return model
772+
def _collect_folder(self, path: Path) -> Folder:
773+
name = path.stem
774+
model = Folder(name, filepath=path, path_collection=self._path_collection)
775+
return self._collect_directory(path, model)
724776

725-
def _collect_readme_md(self, path, parent: MatlabMixin) -> Docstring | None:
777+
def _collect_readme_md(self, path, parent: PathMixin) -> Docstring | None:
726778
if (path / "README.md").exists():
727779
readme = path / "README.md"
728780
elif (path / "readme.md").exists():

src/mkdocstrings_handlers/matlab/handler.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,14 @@ def __init__(
176176
super().__init__(*args, **kwargs)
177177

178178
if paths is None or config_file_path is None:
179+
config_path = None
179180
full_paths = []
180181
else:
181182
config_path = Path(config_file_path).parent
182183
full_paths = [(config_path / path).resolve() for path in paths]
183184

184185
self.paths: PathCollection = PathCollection(
185-
full_paths, recursive=paths_recursive
186+
full_paths, recursive=paths_recursive, config_path=config_path
186187
)
187188
self.lines: LinesCollection = self.paths.lines_collection
188189
self._locale: str = locale
@@ -253,9 +254,7 @@ def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str:
253254
}
254255

255256
# Map docstring options
256-
final_config["show_submodules"] = config.get(
257-
"show_subnamespaces", False
258-
)
257+
final_config["show_submodules"] = config.get("show_subnamespaces", False)
259258
final_config["show_docstring_attributes"] = config.get(
260259
"show_docstring_properties", True
261260
)

0 commit comments

Comments
 (0)