diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-29 04:23:02 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-29 04:23:02 +0000 |
commit | 943e3dc057eca53e68ddec51529bd6a1279ebd8e (patch) | |
tree | 61fb7bac619a56dfbcdcbdb7b0d4d6535fc36fe9 /myst_parser/parsers/directives.py | |
parent | Initial commit. (diff) | |
download | myst-parser-upstream/0.18.1.tar.xz myst-parser-upstream/0.18.1.zip |
Adding upstream version 0.18.1.upstream/0.18.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | myst_parser/parsers/directives.py | 190 |
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 |