Skip to content

Commit 74d56d6

Browse files
gjchong25Grace Chong
andauthored
(DOP-2651) Implement input, output, and io-code-block directives (#375)
* (DOP-2651) Implement input, output, and io-code-block directives * add parser tests and new source option * add logic and tests for handling edge cases * fix formatting and make functions for repeated code * fix breaking tests and reformat code * fix structuring and simplifying logic * add language as an option for input/output, remove it as an argument for io-code-block * fix fstring issue * comment out examples * add arg to rstspec.toml for error checking purposes * fix docstrings Co-authored-by: Grace Chong <[email protected]>
1 parent 769e15b commit 74d56d6

File tree

5 files changed

+605
-67
lines changed

5 files changed

+605
-67
lines changed

snooty/diagnostics.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,18 @@ def __init__(
180180
self.reason = reason
181181

182182

183+
class InvalidDirectiveStructure(Diagnostic):
184+
severity = Diagnostic.Level.error
185+
186+
def __init__(
187+
self,
188+
msg: str,
189+
start: Union[int, Tuple[int, int]],
190+
end: Union[None, int, Tuple[int, int]] = None,
191+
) -> None:
192+
super().__init__(f'Directive "io-code-block" {msg}', start, end)
193+
194+
183195
class InvalidInclude(Diagnostic):
184196
severity = Diagnostic.Level.error
185197

snooty/parser.py

Lines changed: 163 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@
4949
ExpectedPathArg,
5050
FetchError,
5151
ImageSuggested,
52+
InvalidDirectiveStructure,
5253
InvalidField,
5354
InvalidLiteralInclude,
5455
InvalidTableStructure,
5556
InvalidURL,
5657
MalformedGlossary,
58+
MissingChild,
5759
RemovedLiteralBlockSyntax,
5860
TabMustBeDirective,
5961
TodoInfo,
@@ -454,6 +456,9 @@ def dispatch_departure(self, node: docutils.nodes.Node) -> None:
454456
elif isinstance(popped, n.Directive) and popped.name == "tabs":
455457
self.validate_tabs_children(popped)
456458

459+
elif isinstance(popped, n.Directive) and popped.name == "io-code-block":
460+
self.diagnostics.extend(_validate_io_code_block_children(popped))
461+
457462
elif (
458463
isinstance(popped, n.Directive)
459464
and f"{popped.domain}:{popped.name}" == ":glossary"
@@ -700,9 +705,12 @@ def create_page() -> Tuple[Page, EmbeddedRstParser]:
700705
)
701706
return doc
702707

703-
elif name == "literalinclude":
708+
elif name == "literalinclude" or name == "input" or name == "output":
709+
if name == "literalinclude":
710+
if argument_text is None:
711+
self.diagnostics.append(ExpectedPathArg(name, line))
712+
return doc
704713
if argument_text is None:
705-
self.diagnostics.append(ExpectedPathArg(name, line))
706714
return doc
707715

708716
_, filepath = util.reroot_path(
@@ -722,80 +730,88 @@ def create_page() -> Tuple[Page, EmbeddedRstParser]:
722730
return doc
723731

724732
lines = text.split("\n")
733+
# Capture the original file-length before splicing it
734+
len_file = len(lines)
725735

726-
def _locate_text(text: str) -> int:
727-
"""
728-
Searches the literally-included file ('lines') for the specified text. If no such text is found,
729-
add an InvalidLiteralInclude diagnostic.
730-
"""
731-
assert isinstance(text, str)
732-
loc = next((idx for idx, line in enumerate(lines) if text in line), -1)
733-
if loc < 0:
734-
self.diagnostics.append(
735-
InvalidLiteralInclude(f'"{text}" not found in {filepath}', line)
736-
)
737-
return loc
738-
739-
# Locate the start_after query
740-
start_after = 0
741-
if "start-after" in options:
742-
start_after_text = options["start-after"]
743-
start_after = _locate_text(start_after_text)
744-
# Only increment start_after if text is specified, to avoid capturing the start_after_text
745-
start_after += 1
746-
747-
# ...now locate the end_before query
748-
end_before = len(lines)
749-
if "end-before" in options:
750-
end_before_text = options["end-before"]
751-
end_before = _locate_text(end_before_text)
752-
753-
# Check that start_after_text precedes end_before_text (and end_before exists)
754-
if start_after >= end_before >= 0:
755-
self.diagnostics.append(
756-
InvalidLiteralInclude(
757-
f'"{end_before_text}" precedes "{start_after_text}" in {filepath}',
758-
line,
759-
)
760-
)
736+
if name == "literalinclude":
761737

762-
# If we failed to locate end_before text, default to the end-of-file
763-
if end_before == -1:
738+
def _locate_text(text: str) -> int:
739+
"""
740+
Searches the literally-included file ('lines') for the specified text. If no such text is found,
741+
add an InvalidLiteralInclude diagnostic.
742+
"""
743+
assert isinstance(text, str)
744+
loc = next(
745+
(idx for idx, line in enumerate(lines) if text in line), -1
746+
)
747+
if loc < 0:
748+
self.diagnostics.append(
749+
InvalidLiteralInclude(
750+
f'"{text}" not found in {filepath}', line
751+
)
752+
)
753+
return loc
754+
755+
# Locate the start_after query
756+
start_after = 0
757+
if "start-after" in options:
758+
start_after_text = options["start-after"]
759+
# start_after = self._locate_text(start_after_text, lines, line, text)
760+
start_after = _locate_text(start_after_text)
761+
# Only increment start_after if text is specified, to avoid capturing the start_after_text
762+
start_after += 1
763+
764+
# ...now locate the end_before query
764765
end_before = len(lines)
766+
if "end-before" in options:
767+
end_before_text = options["end-before"]
768+
# end_before = self._locate_text(end_before_text, lines, line, text)
769+
end_before = _locate_text(end_before_text)
765770

766-
# Capture the original file-length before splicing it
767-
len_file = len(lines)
768-
lines = lines[start_after:end_before]
769-
770-
dedent = 0
771-
if "dedent" in options:
772-
# Dedent is specified as a flag
773-
if isinstance(options["dedent"], bool):
774-
# Deduce a reasonable dedent
775-
try:
776-
dedent = min(
777-
len(line) - len(line.lstrip())
778-
for line in lines
779-
if len(line.lstrip()) > 0
780-
)
781-
except ValueError:
782-
# Handle the (unlikely) case where there are no non-empty lines
783-
dedent = 0
784-
# Dedent is specified as a nonnegative integer (number of characters):
785-
# Note: since boolean is a subtype of int, this conditonal must follow the
786-
# above bool-type conditional.
787-
elif isinstance(options["dedent"], int):
788-
dedent = options["dedent"]
789-
else:
771+
# Check that start_after_text precedes end_before_text (and end_before exists)
772+
if start_after >= end_before >= 0:
790773
self.diagnostics.append(
791774
InvalidLiteralInclude(
792-
f'Dedent "{dedent}" of type {type(dedent)}; expected nonnegative integer or flag',
775+
f'"{end_before_text}" precedes "{start_after_text}" in {filepath}',
793776
line,
794777
)
795778
)
796-
return doc
797779

798-
lines = [line[dedent:] for line in lines]
780+
# If we failed to locate end_before text, default to the end-of-file
781+
if end_before == -1:
782+
end_before = len(lines)
783+
784+
lines = lines[start_after:end_before]
785+
786+
dedent = 0
787+
if "dedent" in options:
788+
# Dedent is specified as a flag
789+
if isinstance(options["dedent"], bool):
790+
# Deduce a reasonable dedent
791+
try:
792+
dedent = min(
793+
len(line) - len(line.lstrip())
794+
for line in lines
795+
if len(line.lstrip()) > 0
796+
)
797+
except ValueError:
798+
# Handle the (unlikely) case where there are no non-empty lines
799+
dedent = 0
800+
# Dedent is specified as a nonnegative integer (number of characters):
801+
# Note: since boolean is a subtype of int, this conditonal must follow the
802+
# above bool-type conditional.
803+
elif isinstance(options["dedent"], int):
804+
dedent = options["dedent"]
805+
else:
806+
self.diagnostics.append(
807+
InvalidLiteralInclude(
808+
f'Dedent "{dedent}" of type {type(dedent)}; expected nonnegative integer or flag',
809+
line,
810+
)
811+
)
812+
return doc
813+
814+
lines = [line[dedent:] for line in lines]
799815

800816
emphasize_lines = None
801817
if "emphasize-lines" in options:
@@ -806,7 +822,8 @@ def _locate_text(text: str) -> int:
806822
except ValueError as err:
807823
self.diagnostics.append(
808824
InvalidLiteralInclude(
809-
f"Invalid emphasize-lines specification caused: {err}", line
825+
f"Invalid emphasize-lines specification caused: {err}",
826+
line,
810827
)
811828
)
812829

@@ -833,6 +850,16 @@ def _locate_text(text: str) -> int:
833850

834851
doc.children.append(code)
835852

853+
elif name == "io-code-block":
854+
if argument_text is not None:
855+
self.diagnostics.append(
856+
InvalidDirectiveStructure(
857+
"did not expect an argument, language should be passed as an option to input/output directives",
858+
line,
859+
)
860+
)
861+
return doc
862+
836863
elif name == "include":
837864
if argument_text is None:
838865
self.diagnostics.append(ExpectedPathArg(name, util.get_line(node)))
@@ -984,6 +1011,75 @@ def __make_child_visitor(self) -> "JSONVisitor":
9841011
return visitor
9851012

9861013

1014+
def _validate_io_code_block_children(node: n.Directive) -> List[Diagnostic]:
1015+
"""Validates that a given io-code-block directive has 1 input and 1 output
1016+
child nodes, and copies the io-code-block's options into the options of the
1017+
underlying code nodes."""
1018+
# new_children should contain input and output directives
1019+
new_children: List[n.Node] = []
1020+
line = node.start[0]
1021+
expected_children = {"input", "output"}
1022+
diagnostics: List[Diagnostic] = []
1023+
1024+
for child in node.children:
1025+
if not isinstance(child, n.Directive):
1026+
diagnostics.append(
1027+
InvalidDirectiveStructure(
1028+
f"expected input/output child directives, saw {child.type}",
1029+
line,
1030+
)
1031+
)
1032+
continue
1033+
1034+
if child.name in expected_children:
1035+
new_grandchildren: List[n.Node] = []
1036+
1037+
# Input or output should have 1 child Code node
1038+
if len(child.children) == 1:
1039+
expected_children.remove(child.name)
1040+
1041+
# child nodes for input/output will inherit parent options
1042+
grandchild = child.children[0]
1043+
if isinstance(grandchild, n.Code):
1044+
grandchild.lang = (
1045+
child.options["language"]
1046+
if "language" in child.options
1047+
else None
1048+
)
1049+
grandchild.caption = (
1050+
node.options["caption"] if "caption" in node.options else None
1051+
)
1052+
grandchild.copyable = (
1053+
True
1054+
if "copyable" in node.options and node.options["copyable"]
1055+
else False
1056+
)
1057+
new_grandchildren.append(grandchild)
1058+
child.children = new_grandchildren
1059+
new_children.append(child)
1060+
else:
1061+
# either duplicate input/output or invalid child is provided
1062+
msg = f"already contains an {child.name} directive"
1063+
if not (child.name == "input" or child.name == "output"):
1064+
msg = f"does not accept child {child.name}"
1065+
diagnostics.append(
1066+
InvalidDirectiveStructure(
1067+
msg,
1068+
line,
1069+
)
1070+
)
1071+
1072+
# handle missing nested input and/or output directives
1073+
if len(expected_children) != 0 or len(new_children) != 2:
1074+
for expected_child in expected_children:
1075+
diagnostics.append(MissingChild("io-code-block", expected_child, line))
1076+
if expected_child == "input":
1077+
new_children = []
1078+
1079+
node.children = new_children
1080+
return diagnostics
1081+
1082+
9871083
class InlineJSONVisitor(JSONVisitor):
9881084
"""A JSONVisitor subclass which does not emit block nodes."""
9891085

snooty/rstparser.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,55 @@ def run(self) -> List[docutils.nodes.Node]:
809809
return [node]
810810

811811

812+
class BaseCodeIODirective(docutils.parsers.rst.Directive):
813+
"""Special handling for code input/output directives.
814+
815+
These directives can either take in a filepath or raw code content. If a filepath
816+
is present, this should be included in the `argument` field of the AST. If raw code
817+
content is present, it should become the value of the child Code node.
818+
"""
819+
820+
optional_arguments = 1
821+
822+
def run(self) -> List[docutils.nodes.Node]:
823+
source, line = self.state_machine.get_source_and_line(self.lineno)
824+
copyable = "copyable" not in self.options or self.options["copyable"]
825+
linenos = "linenos" in self.options
826+
827+
node = directive("", self.name)
828+
node.document = self.state.document
829+
node.source, node.line = source, line
830+
node["options"] = self.options
831+
832+
if self.arguments:
833+
title_node: docutils.nodes.Node = docutils.nodes.Text(self.arguments[0])
834+
node.append(directive_argument(self.arguments[0], "", title_node))
835+
else:
836+
try:
837+
n_lines = len(self.content)
838+
emphasize_lines = parse_linenos(
839+
self.options.get("emphasize-lines", ""), n_lines
840+
)
841+
except ValueError as err:
842+
error_node = self.state.document.reporter.error(
843+
str(err), line=self.lineno
844+
)
845+
return [error_node]
846+
847+
value = "\n".join(self.content)
848+
child_code = code(value, value)
849+
child_code["name"] = "code"
850+
child_code["emphasize_lines"] = emphasize_lines
851+
child_code["linenos"] = linenos
852+
child_code["copyable"] = copyable
853+
854+
child_code.document = self.state.document
855+
child_code.source, node.line = source, line
856+
node.append(child_code)
857+
858+
return [node]
859+
860+
812861
class BaseVersionDirective(docutils.parsers.rst.Directive):
813862
"""Special handling for version change directives.
814863
@@ -1030,6 +1079,8 @@ def get(cls, default_domain: Optional[str]) -> "Registry":
10301079
SPECIAL_DIRECTIVE_HANDLERS: Dict[str, Type[docutils.parsers.rst.Directive]] = {
10311080
"code-block": BaseCodeDirective,
10321081
"code": BaseCodeDirective,
1082+
"input": BaseCodeIODirective,
1083+
"output": BaseCodeIODirective,
10331084
"sourcecode": BaseCodeDirective,
10341085
"versionadded": BaseVersionDirective,
10351086
"versionchanged": BaseVersionDirective,

0 commit comments

Comments
 (0)