Skip to content
Merged
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
137 changes: 96 additions & 41 deletions docs/_ext/rule_directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,17 @@

from __future__ import annotations

import functools
import typing
from dataclasses import dataclass
from types import SimpleNamespace
from typing import cast

import docutils.frontend
from docutils import nodes
from docutils.parsers.rst import Parser
from docutils.parsers.rst.states import Inliner
from docutils.utils import new_document
from sphinx.addnodes import pending_xref
from sphinx.domains import Domain, ObjType
from sphinx.roles import XRefRole
Expand All @@ -33,7 +40,7 @@
from collections.abc import Set
from typing import Any, ClassVar

from docutils.nodes import Element, Node, document, system_message
from docutils.nodes import Element, Node, document, reference, system_message
from sphinx.application import Sphinx
from sphinx.builders import Builder
from sphinx.environment import BuildEnvironment
Expand All @@ -53,10 +60,47 @@ def setup(app: Sphinx):
}


class RuleEntry(typing.NamedTuple):
@dataclass(frozen=True)
class RuleEntry:

code: str
title: str
doc_name: str
node_id: str
rule_name: str

def __post_init__(self):
object.__setattr__(self, "code", self.code.upper())

@functools.cached_property
def ref_name(self) -> str:
return "tg.rule." + self.code.lower()

@functools.cached_property
def node_id(self) -> str:
return nodes.make_id(self.ref_name)

def title_nodes(self) -> list[Node]:
settings = docutils.frontend.get_default_settings(Parser())
doc = new_document("<rule-title>", settings=settings)

inliner = Inliner()
inliner.init_customizations(settings)
memo = SimpleNamespace(document=doc, reporter=doc.reporter, language=None)

text_nodes, _ = inliner.parse(self.title, 1, memo, doc)
return text_nodes

def title_stripped(self) -> str:
return "".join(node.astext() for node in self.title_nodes())

def node(self):
"""Create reference node for this rule."""
container = nodes.inline(classes=["inline-rule-ref"])
container += [
nodes.inline(text=self.code, classes=["rule-code"]),
nodes.Text(" "),
*self.title_nodes(),
]
return container


class RuleDirective(SphinxDirective):
Expand All @@ -71,18 +115,9 @@ def run(self):
rule_code, rule_name = self.arguments
rule_code = rule_code.upper()

ref_name = "tg.rule." + rule_code.lower()
node_id = nodes.make_id(ref_name)

# register the label in Sphinx's standard domain
std_domain = self.env.domains.standard_domain
std_domain.note_hyperlink_target(
ref_name, self.env.docname, node_id, f"{rule_code} ({rule_name})"
)

# register the rule in tugboat domain
tugboat_domain = cast("TugboatDomain", self.env.get_domain("tg"))
tugboat_domain.note_rule(rule_code, rule_name, node_id)
entry = tugboat_domain.note_rule(rule_code, rule_name)

# create section and populate it with title and content
rule_name_nodes, _ = self.parse_inline(rule_name)
Expand All @@ -98,7 +133,7 @@ def run(self):
section += title
section += self.parse_content_to_nodes()

target = nodes.target(ids=[node_id])
target = nodes.target(ids=[entry.node_id])

return [target, section]

Expand Down Expand Up @@ -134,25 +169,15 @@ def result_nodes(
rule_code: str = node["reftarget"]

domain = cast("TugboatDomain", env.get_domain("tg"))
data = domain.rules.get(rule_code)
if data and not node.get("explicit"):
entry = domain.rules.get(rule_code.upper())

if entry and not node.get("explicit"):
node.clear()
node += make_rule_contnode(rule_code, data.rule_name)
node += entry.node()

return [node], []


def make_rule_contnode(rule_code: str, rule_name: str) -> Node:
"""Create content node for a rule reference."""
container = nodes.inline(classes=["inline-rule-ref"])
container += [
nodes.inline(text=rule_code, classes=["rule-code"]),
nodes.Text(" "),
nodes.Text(rule_name),
]
return container


class TugboatDomain(Domain):

name = "tg"
Expand All @@ -166,16 +191,45 @@ class TugboatDomain(Domain):
def rules(self) -> dict[str, RuleEntry]:
return self.data.setdefault("rules", {})

def note_rule(self, rule_code: str, rule_name: str, node_id: str):
self.rules[rule_code.upper()] = RuleEntry(
def note_rule(self, rule_code: str, rule_name: str) -> RuleEntry:
"""
Note a rule in the domain.

Parameters
----------
rule_code : str
The unique code for the rule.
rule_name : str
The name of the rule.

Returns
-------
entry : RuleEntry
The registered rule entry.
"""
# register a rule in this domain
entry = RuleEntry(
code=rule_code,
title=rule_name,
doc_name=self.env.docname,
node_id=node_id,
rule_name=rule_name,
)

self.rules[entry.code] = entry

# register the label in Sphinx's standard domain
std_domain = self.env.domains.standard_domain
std_domain.note_hyperlink_target(
name=entry.ref_name,
docname=self.env.docname,
node_id=entry.node_id,
title=f"{entry.code} ({entry.title_stripped()})",
)

return entry

def get_objects(self):
for label, (doc_name, node_id, rule_name) in self.rules.items():
yield (label, rule_name, "rule", doc_name, node_id, 1)
for label, rule in self.rules.items():
yield (label, rule.title, "rule", rule.doc_name, rule.node_id, 1)

def clear_doc(self, docname: str):
to_remove = []
Expand All @@ -199,14 +253,15 @@ def resolve_xref(
target: str,
node: pending_xref,
contnode: Element,
) -> Element | None:
if data := self.rules.get(target):
) -> reference | None:
if entry := self.rules.get(target):
return make_refnode(
builder,
fromdocname,
data.doc_name,
data.node_id,
make_rule_contnode(target, data.rule_name),
data.node_id,
entry.doc_name,
entry.node_id,
entry.node(),
entry.title,
)

return None