Skip to content

Commit

Permalink
DOP-4685: Implement method-selector (#614)
Browse files Browse the repository at this point in the history
  • Loading branch information
rayangler authored Sep 3, 2024
1 parent b5ec8a5 commit 75a1dfa
Show file tree
Hide file tree
Showing 7 changed files with 701 additions and 71 deletions.
40 changes: 36 additions & 4 deletions snooty/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,34 +962,66 @@ def __init__(
)


class UnknownWayfindingOption(Diagnostic):
class UnknownOptionId(Diagnostic):
severity = Diagnostic.Level.error

def __init__(
self,
directive_name: str,
invalid_id: str,
valid_ids: List[str],
start: Union[int, Tuple[int, int]],
end: Union[None, int, Tuple[int, int]] = None,
):
super().__init__(
f"Wayfinding option id {invalid_id} is not valid. Expected one of the following: {valid_ids}",
f"{directive_name} id {invalid_id} is not valid. Expected one of the following: {valid_ids}",
start,
end,
)


class DuplicateWayfindingOption(Diagnostic):
class DuplicateOptionId(Diagnostic):
severity = Diagnostic.Level.error

def __init__(
self,
directive_name: str,
invalid_id: str,
start: Union[int, Tuple[int, int]],
end: Union[None, int, Tuple[int, int]] = None,
):
super().__init__(
f"Wayfinding option id {invalid_id} is already used in current wayfinding context.",
f"{directive_name} option id {invalid_id} is already used in current context.",
start,
end,
)


class UnexpectedDirectiveOrder(Diagnostic):
severity = Diagnostic.Level.warning

def __init__(
self,
message: str,
start: Union[int, Tuple[int, int]],
end: Union[None, int, Tuple[int, int]] = None,
):
super().__init__(f"Unexpected directive order. Expected: {message}", start, end)


class InvalidChildCount(Diagnostic):
severity = Diagnostic.Level.error

def __init__(
self,
parent_name: str,
child_name: str,
expected: str,
start: Union[int, Tuple[int, int]],
end: Union[None, int, Tuple[int, int]] = None,
):
super().__init__(
f"Unexpected number of {child_name} in {parent_name}. Expected: {expected}",
start,
end,
)
198 changes: 151 additions & 47 deletions snooty/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@
ConfigurationProblem,
Diagnostic,
DocUtilsParseError,
DuplicateWayfindingOption,
DuplicateOptionId,
ExpectedOption,
ExpectedPathArg,
ExpectedStringArg,
FetchError,
IconMustBeDefined,
ImageSuggested,
InvalidChild,
InvalidChildCount,
InvalidDirectiveStructure,
InvalidField,
InvalidLiteralInclude,
Expand All @@ -73,10 +74,11 @@
RemovedLiteralBlockSyntax,
TabMustBeDirective,
TodoInfo,
UnexpectedDirectiveOrder,
UnexpectedIndentation,
UnknownOptionId,
UnknownTabID,
UnknownTabset,
UnknownWayfindingOption,
UnmarshallingError,
)
from .icon_names import ICON_SET, LG_ICON_SET
Expand Down Expand Up @@ -107,6 +109,10 @@ class ProjectLoadError(Exception):
pass


class ChildValidationError(Exception):
pass


def eligible_for_paragraph_to_block_substitution(node: tinydocutils.nodes.Node) -> bool:
"""Test if a docutils node should emit a BlockSubstitutionReference *instead* of a normal
Paragraph."""
Expand Down Expand Up @@ -560,6 +566,12 @@ def dispatch_departure(self, node: tinydocutils.nodes.Node) -> None:
elif isinstance(popped, n.Directive) and popped.name == "wayfinding":
self.handle_wayfinding(popped)

elif isinstance(popped, n.Directive) and popped.name == "method-selector":
self.handle_method_selector(popped)

elif isinstance(popped, n.Directive) and popped.name == "method-option":
self.handle_method_option(popped)

def handle_facet(self, node: rstparser.directive, line: int) -> None:
if "values" not in node["options"] or "name" not in node["options"]:
return
Expand Down Expand Up @@ -664,53 +676,18 @@ def handle_wayfinding(self, node: n.Directive) -> None:

# Validate children
for child in node.children:
child_line_start = child.start[0]

invalid_child = None
if not isinstance(child, n.Directive):
# Catches additional unwanted types like Paragraph
invalid_child = child.type
elif not child.name in expected_children_names:
invalid_child = child.name

if invalid_child:
self.diagnostics.append(
InvalidChild(
invalid_child,
wayfinding_name,
f"{expected_child_opt_name} or {expected_child_desc_name}",
child_line_start,
)
)
continue

# Type is ambiguous now despite if statements above
assert isinstance(child, n.Directive)
if child.name == expected_child_desc_name:
valid_desc = child
continue

option_id = child.options.get("id")

if not (child.argument and option_id):
# Don't append diagnostic since docutils should already
# complain about missing argument and ID option
continue

if not option_id in expected_options_dict:
available_ids = list(expected_options_dict.keys())
available_ids.sort()
self.diagnostics.append(
UnknownWayfindingOption(option_id, available_ids, child_line_start)
)
continue

if option_id in used_ids:
self.diagnostics.append(
DuplicateWayfindingOption(option_id, child_line_start)
)
try:
self.check_valid_child(node, child, expected_children_names)
# check_valid_child verifies that the child is a directive
assert isinstance(child, n.Directive)
if child.name == expected_child_desc_name:
valid_desc = child
continue
self.check_valid_option_id(child, expected_options_dict, used_ids)
except ChildValidationError:
continue

option_id = child.options.get("id", "")
option_details = expected_options_dict[option_id]
child.options["title"] = option_details.title
child.options["language"] = option_details.language
Expand Down Expand Up @@ -743,6 +720,133 @@ def sort_key(node: n.Directive) -> tuple[bool, str, str]:

node.children = cast(List[n.Node], valid_children)

def check_valid_child(
self,
parent: n.Directive,
child: n.Node,
expected_children_names: Set[str],
) -> None:
"""
Ensures that a child node matches a name that a parent directive expects. Valid
children are expected to be directives.
"""

invalid_child = None
if not isinstance(child, n.Directive):
# Catches additional unwanted types like Paragraph
invalid_child = child.type
elif not child.name in expected_children_names:
invalid_child = child.name

if invalid_child:
expected_children_str = (
next(iter(expected_children_names))
if len(expected_children_names) == 1
else str(expected_children_names)
)
self.diagnostics.append(
InvalidChild(
invalid_child,
parent.name,
expected_children_str,
child.start[0],
)
)
raise ChildValidationError()

def check_valid_option_id(
self,
child: n.Directive,
expected_options: Dict[str, Any],
used_ids: Set[str],
) -> None:
"""Ensures that a child directive has a unique option "id" that is correctly defined."""

option_id = child.options.get("id")
if not option_id:
# Don't append diagnostic since docutils should already
# complain about missing ID option
raise ChildValidationError()

if not option_id in expected_options:
available_ids = list(expected_options.keys())
available_ids.sort()
self.diagnostics.append(
UnknownOptionId(child.name, option_id, available_ids, child.start[0])
)
raise ChildValidationError()

if option_id in used_ids:
self.diagnostics.append(
DuplicateOptionId(child.name, option_id, child.start[0])
)
raise ChildValidationError()

def handle_method_selector(self, node: n.Directive) -> None:
expected_options = specparser.Spec.get().method_selector["options"]
expected_options_dict = {option.id: option for option in expected_options}
expected_child_name = "method-option"

valid_children: List[n.Directive] = []
used_ids: Set[str] = set()

# Validate children
for child in node.children:
try:
self.check_valid_child(node, child, {expected_child_name})
# check_valid_child verifies that the child is a directive
assert isinstance(child, n.Directive)
self.check_valid_option_id(child, expected_options_dict, used_ids)
except ChildValidationError:
continue

# The Drivers option should be encouraged to be first
option_id = child.options.get("id", "")
if option_id == "driver" and valid_children:
self.diagnostics.append(
UnexpectedDirectiveOrder(
f'{child.name} with id "{option_id}" should be the first child of {node.name}',
child.start[0],
)
)

option_details = expected_options_dict[option_id]
child.options["title"] = option_details.title
valid_children.append(child)
used_ids.add(option_id)

if len(valid_children) < 2 or len(valid_children) > 6:
self.diagnostics.append(
InvalidChildCount(
node.name, expected_child_name, "2-6 options", node.start[0]
)
)

node.children = cast(List[n.Node], valid_children)

def handle_method_option(self, node: n.Directive) -> None:
"""Moves method-description as the first child of the option to help enforce order."""

expected_desc_name = "method-description"
target_idx = -1

for idx, child in enumerate(node.children):
if isinstance(child, n.Directive) and child.name == expected_desc_name:
target_idx = idx

if idx != 0:
self.diagnostics.append(
UnexpectedDirectiveOrder(
f"{expected_desc_name} should be the first child of {node.name}",
child.start[0],
)
)

break

if target_idx >= 0:
node.children.insert(0, node.children.pop(target_idx))

def handle_directive(
self, node: rstparser.directive, line: int
) -> Optional[n.Node]:
Expand Down
Loading

0 comments on commit 75a1dfa

Please sign in to comment.