From f9e4a0f0b7265975d8b981f2e39fba4ba332833d Mon Sep 17 00:00:00 2001 From: Dennis den Ouden-van der Horst Date: Fri, 2 Jan 2026 20:05:44 +0100 Subject: [PATCH] Improve gated directive nesting validation and docs Added a robust validate_SE function to check for correct nesting of start and end directives, with detailed error reporting. Updated the CheckGatedDirectivesTransform to use this validation and improved error messages for mismatched or incorrectly nested directives. Extended intro.md with comprehensive examples of nested directives, including both valid and invalid cases, to document and test the new behavior. Also added the sphinx_tudelft_theme extension in _config.yml. --- book/_config.yml | 1 + book/_ext/sphinx_gated_directives/__init__.py | 88 +++++++++++-- book/intro.md | 120 ++++++++++++++++++ 3 files changed, 198 insertions(+), 11 deletions(-) diff --git a/book/_config.yml b/book/_config.yml index 580aa76..1e55627 100644 --- a/book/_config.yml +++ b/book/_config.yml @@ -19,6 +19,7 @@ sphinx: - teachbooks_favourites - teachbooks_sphinx_grasple - sphinx_apa_references + - sphinx_tudelft_theme config: language: en html_js_files: diff --git a/book/_ext/sphinx_gated_directives/__init__.py b/book/_ext/sphinx_gated_directives/__init__.py index 7afe003..d213d60 100644 --- a/book/_ext/sphinx_gated_directives/__init__.py +++ b/book/_ext/sphinx_gated_directives/__init__.py @@ -30,6 +30,9 @@ from docutils import nodes from docutils.parsers.rst import Directive from docutils.parsers.rst import directives as du_directives +from dataclasses import dataclass, field +from typing import Dict, Optional + SUFFIX_START = "-start" SUFFIX_END = "-end" @@ -142,7 +145,7 @@ def make_startified_class( else: children = [children] # create a start_node and add all result nodes as its children - start_node_instance = start_node() + start_node_instance = start_node(orig_name) start_node_instance += children result = [start_node_instance] @@ -267,7 +270,7 @@ def make_endified_class( # return [] # next line should be good enough, but raises an error as not all has been implemented # maybe we need end nodes per directive type? - return [end_node()] + return [end_node(orig_name)] attrs["run"] = run new_cls_name = f"{base_cls.__name__}_End_For_{orig_name.replace(':', '_')}" @@ -333,10 +336,15 @@ def setup(app): # Node classes for start and end directives class start_node(nodes.Admonition, nodes.Element): - pass + + def __init__(self, type_of_directive: str=""): + self.type_of_directive = type_of_directive + super().__init__() class end_node(nodes.Admonition, nodes.Element): - pass + def __init__(self, type_of_directive: str=""): + self.type_of_directive = type_of_directive + super().__init__() # purge_registry def purge_registries(app: Sphinx, env: BuildEnvironment, docname: str) -> None: @@ -375,7 +383,7 @@ class CheckGatedDirectivesTransform(SphinxTransform): end = super_registry[docname]["end"] structure = "\n ".join(super_registry[docname]["msg"]) sequence = "".join(super_registry[docname]["sequence"]) - groups = re.findall("(SE)", sequence) + validate_SE_result = validate_SE(sequence) if len(start) > len(end): msg = f"The document {docname} contains more start directives than end directives:\n {structure}\nPlease ensure each start directive has a corresponding end directive." logger.error(msg) @@ -385,21 +393,23 @@ class CheckGatedDirectivesTransform(SphinxTransform): logger.error(msg) error = True # at this point, len(start) == len(end) - elif len(groups) != len(start) or len(groups) != len(end): - msg = f"The document {docname} contains nested start and end directives:\n {structure}\nThis is not allowed. Please correct the nesting." + # now check for correct nesting + elif not validate_SE_result.is_valid: + msg = f"The document {docname} contains incorrectly nested start and end directives:\n {structure}\nThis is not allowed. Please correct the nesting." logger.error(msg) error = True else: - # At this point, every start is followed by an end. + # At this point, every start is matched with an end # Now check that types match in order. types = super_registry[docname]["type"] - start_type = types[::2] - end_type = types[1::2] + start_type = [types[k] for k in validate_SE_result.pairs.keys()] + end_type = [types[v] for v in validate_SE_result.pairs.values()] for i in range(len(start_type)): if start_type[i] != end_type[i]: msg = f"The document {docname} contains mismatched start and end directives at lines {start[i]} and {end[i]}:\n {structure}\nPlease ensure that start and end directives match in type." logger.error(msg) error = True + break if error: raise Exception(f"[sphinx-gated-directives] An error has occurred when parsing gated directives in {docname}.\nPlease check warning messages above.") @@ -505,4 +515,60 @@ def findall(node: Element, *args, **kwargs) -> Iterator[Element]: # findall replaces traverse in docutils v0.18 # note a difference is that findall is an iterator impl = getattr(node, "findall", node.traverse) - return iter(impl(*args, **kwargs)) \ No newline at end of file + return iter(impl(*args, **kwargs)) + +@dataclass +class SEValidationResult: + is_valid: bool + pairs: Dict[int, int] = field(default_factory=dict) # {S_index: E_index} + error_index: Optional[int] = None # index of first error, if any + error_message: Optional[str] = None # description of the error, if any + +def validate_SE(s: str) -> SEValidationResult: + """ + Validates a string containing only 'S' (open) and 'E' (close). + Returns: + SEValidationResult with: + - is_valid: True iff all brackets match and no invalid characters occur. + - pairs: dict mapping each 'S' index to the corresponding 'E' index. + (zero-based indices in the original string) + - error_index, error_message: populated on first failure. + Errors reported: + * Invalid character (not 'S' or 'E'). + * 'E' encountered before any available 'S'. + * Unmatched 'S' remaining at the end. + The pairs dict is still returned for all successfully matched pairs up to the error. + """ + stack = [] # stack of indices of unmatched 'S' + pairs: Dict[int, int] = {} + + for i, ch in enumerate(s): + if ch == 'S': + stack.append(i) + elif ch == 'E': + if not stack: + return SEValidationResult( + is_valid=False, + pairs=pairs, + error_index=i, + error_message="Encountered 'E' before a matching 'S'." + ) + s_index = stack.pop() + pairs[s_index] = i + else: + return SEValidationResult( + is_valid=False, + pairs=pairs, + error_index=i, + error_message=f"Invalid character: {ch!r}. Only 'S' and 'E' are allowed." + ) + + if stack: + return SEValidationResult( + is_valid=False, + pairs=pairs, + error_index=len(s), + error_message=f"{len(stack)} unmatched 'S' remaining at end." + ) + + return SEValidationResult(is_valid=True, pairs=pairs) diff --git a/book/intro.md b/book/intro.md index 204b5a3..c70a559 100644 --- a/book/intro.md +++ b/book/intro.md @@ -6,6 +6,124 @@ kernelspec: # TeachBooks Manual +## Nesting inside of a start-directive + +### Different directives + +::::{prf:theorem-start} The parent theorem +:label: parent-theorem-label + +This is a parent theorem. This text is inside the parent theorem. + +:::{prf:axiom-start} A child theorem +:label: child-axiom-label + +This is a child axiom. This text is inside the child directive. +::: + +This is text after the child-start, before the child-end, inside the parent theorem. + +:::{prf:axiom-end} +::: + +Finally some text after the entire child, inside the theorem start. + +:::: + +Some text after the entire parent theorem start, but before the parent theorem end. + +:::{prf:theorem-end} +::: + +### Same directives + +::::{prf:theorem-start} The parent theorem +:label: parent-theorem-label2 + +This is a parent theorem. This text is inside the parent theorem. + +:::{prf:theorem-start} A child theorem +:label: child-theorem-label + +This is a child theorem. This text is inside the child directive. +::: + +This is text after the child-start, before the child-end, inside the parent theorem. + +:::{prf:theorem-end} +::: + +Finally some text after the entire child, inside the theorem start. + +:::: + +Some text after the entire parent theorem start, but before the parent theorem end. + +:::{prf:theorem-end} +::: + +## Nesting outside of a start-directive + +### Different directives + +Crashes still: + + + +### Same directives + +Crashes still: + + + +## Examples with code + +### Block directive with also a figure + :::{prf:theorem-start} A title :label: theorem-label @@ -32,6 +150,8 @@ print(f"{a} {b} {c}") :::{prf:theorem-end} ::: +### Figure directive with nothing with also another figure + :::{figure-start} images/nothing.svg :name: figure-label :alt: Nothing