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.
This commit is contained in:
Dennis den Ouden-van der Horst 2026-01-02 20:05:44 +01:00
parent a5f014ddf6
commit f9e4a0f0b7
3 changed files with 198 additions and 11 deletions

View file

@ -19,6 +19,7 @@ sphinx:
- teachbooks_favourites
- teachbooks_sphinx_grasple
- sphinx_apa_references
- sphinx_tudelft_theme
config:
language: en
html_js_files:

View file

@ -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))
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)

View file

@ -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:
<!-- ::::{prf:theorem-start} The parent theorem
:label: parent-theorem-label3
This is a parent theorem. This text is inside the parent theorem.
::::
Some text outside the parent theorem start, before the child start directive.
:::{prf:axiom-start} A child theorem
:label: child-axiom-label2
This is a child axiom. This text is inside the child directive.
:::
This is text after the child-start, before the child-end.
:::{prf:axiom-end}
:::
Finally some text after the entire child, outside the theorem start, before the end.
:::{prf:theorem-end}
::: -->
### Same directives
Crashes still:
<!-- ::::{prf:theorem-start} The parent theorem
:label: parent-theorem-label4
This is a parent theorem. This text is inside the parent theorem.
::::
Some text outside the parent theorem start, before the child start directive.
:::{prf:theorem-start} A child theorem
:label: child-theorem-label5
This is a child theorem. This text is inside the child directive.
:::
This is text after the child-start, before the child-end.
:::{prf:theorem-end}
:::
Finally some text after the entire child, outside the theorem start, before the end.
:::{prf:theorem-end}
::: -->
## 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