summaryrefslogtreecommitdiffstats
path: root/myst_parser/parsers/directives.py
diff options
context:
space:
mode:
Diffstat (limited to 'myst_parser/parsers/directives.py')
-rw-r--r--myst_parser/parsers/directives.py190
1 files changed, 190 insertions, 0 deletions
diff --git a/myst_parser/parsers/directives.py b/myst_parser/parsers/directives.py
new file mode 100644
index 0000000..5637254
--- /dev/null
+++ b/myst_parser/parsers/directives.py
@@ -0,0 +1,190 @@
+"""Fenced code blocks are parsed as directives,
+if the block starts with ``{directive_name}``,
+followed by arguments on the same line.
+
+Directive options are read from a YAML block,
+if the first content line starts with ``---``, e.g.
+
+::
+
+ ```{directive_name} arguments
+ ---
+ option1: name
+ option2: |
+ Longer text block
+ ---
+ content...
+ ```
+
+Or the option block will be parsed if the first content line starts with ``:``,
+as a YAML block consisting of every line that starts with a ``:``, e.g.
+
+::
+
+ ```{directive_name} arguments
+ :option1: name
+ :option2: other
+
+ content...
+ ```
+
+If the first line of a directive's content is blank, this will be stripped
+from the content.
+This is to allow for separation between the option block and content.
+
+"""
+from __future__ import annotations
+
+import datetime
+import re
+from textwrap import dedent
+from typing import Any, Callable
+
+import yaml
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst.directives.misc import TestDirective
+
+
+class DirectiveParsingError(Exception):
+ """Raise on parsing/validation error."""
+
+ pass
+
+
+def parse_directive_text(
+ directive_class: type[Directive],
+ first_line: str,
+ content: str,
+ validate_options: bool = True,
+) -> tuple[list[str], dict, list[str], int]:
+ """Parse (and validate) the full directive text.
+
+ :param first_line: The text on the same line as the directive name.
+ May be an argument or body text, dependent on the directive
+ :param content: All text after the first line. Can include options.
+ :param validate_options: Whether to validate the values of options
+
+ :returns: (arguments, options, body_lines, content_offset)
+ """
+ if directive_class.option_spec:
+ body, options = parse_directive_options(
+ content, directive_class, validate=validate_options
+ )
+ body_lines = body.splitlines()
+ content_offset = len(content.splitlines()) - len(body_lines)
+ else:
+ # If there are no possible options, we do not look for a YAML block
+ options = {}
+ body_lines = content.splitlines()
+ content_offset = 0
+
+ if not (directive_class.required_arguments or directive_class.optional_arguments):
+ # If there are no possible arguments, then the body starts on the argument line
+ if first_line:
+ body_lines.insert(0, first_line)
+ arguments = []
+ else:
+ arguments = parse_directive_arguments(directive_class, first_line)
+
+ # remove first line of body if blank
+ # this is to allow space between the options and the content
+ if body_lines and not body_lines[0].strip():
+ body_lines = body_lines[1:]
+ content_offset += 1
+
+ # check for body content
+ if body_lines and not directive_class.has_content:
+ raise DirectiveParsingError("No content permitted")
+
+ return arguments, options, body_lines, content_offset
+
+
+def parse_directive_options(
+ content: str, directive_class: type[Directive], validate: bool = True
+):
+ """Parse (and validate) the directive option section."""
+ options: dict[str, Any] = {}
+ if content.startswith("---"):
+ content = "\n".join(content.splitlines()[1:])
+ match = re.search(r"^-{3,}", content, re.MULTILINE)
+ if match:
+ yaml_block = content[: match.start()]
+ content = content[match.end() + 1 :] # TODO advance line number
+ else:
+ yaml_block = content
+ content = ""
+ yaml_block = dedent(yaml_block)
+ try:
+ options = yaml.safe_load(yaml_block) or {}
+ except (yaml.parser.ParserError, yaml.scanner.ScannerError) as error:
+ raise DirectiveParsingError("Invalid options YAML: " + str(error))
+ elif content.lstrip().startswith(":"):
+ content_lines = content.splitlines() # type: list
+ yaml_lines = []
+ while content_lines:
+ if not content_lines[0].lstrip().startswith(":"):
+ break
+ yaml_lines.append(content_lines.pop(0).lstrip()[1:])
+ yaml_block = "\n".join(yaml_lines)
+ content = "\n".join(content_lines)
+ try:
+ options = yaml.safe_load(yaml_block) or {}
+ except (yaml.parser.ParserError, yaml.scanner.ScannerError) as error:
+ raise DirectiveParsingError("Invalid options YAML: " + str(error))
+ if not isinstance(options, dict):
+ raise DirectiveParsingError(f"Invalid options (not dict): {options}")
+
+ if (not validate) or issubclass(directive_class, TestDirective):
+ # technically this directive spec only accepts one option ('option')
+ # but since its for testing only we accept all options
+ return content, options
+
+ # check options against spec
+ options_spec: dict[str, Callable] = directive_class.option_spec
+ for name, value in list(options.items()):
+ try:
+ convertor = options_spec[name]
+ except KeyError:
+ raise DirectiveParsingError(f"Unknown option: {name}")
+ if not isinstance(value, str):
+ if value is True or value is None:
+ value = None # flag converter requires no argument
+ elif isinstance(value, (int, float, datetime.date, datetime.datetime)):
+ # convertor always requires string input
+ value = str(value)
+ else:
+ raise DirectiveParsingError(
+ f'option "{name}" value not string (enclose with ""): {value}'
+ )
+ try:
+ converted_value = convertor(value)
+ except (ValueError, TypeError) as error:
+ raise DirectiveParsingError(
+ "Invalid option value: (option: '{}'; value: {})\n{}".format(
+ name, value, error
+ )
+ )
+ options[name] = converted_value
+
+ return content, options
+
+
+def parse_directive_arguments(directive, arg_text):
+ """Parse (and validate) the directive argument section."""
+ required = directive.required_arguments
+ optional = directive.optional_arguments
+ arguments = arg_text.split()
+ if len(arguments) < required:
+ raise DirectiveParsingError(
+ f"{required} argument(s) required, {len(arguments)} supplied"
+ )
+ elif len(arguments) > required + optional:
+ if directive.final_argument_whitespace:
+ arguments = arg_text.split(None, required + optional - 1)
+ else:
+ raise DirectiveParsingError(
+ "maximum {} argument(s) allowed, {} supplied".format(
+ required + optional, len(arguments)
+ )
+ )
+ return arguments