Add `tests/utils/python/normand.py` (Normand 0.23)
authorPhilippe Proulx <eeppeliteloop@gmail.com>
Thu, 21 Sep 2023 18:16:01 +0000 (14:16 -0400)
committerPhilippe Proulx <eeppeliteloop@gmail.com>
Tue, 17 Oct 2023 14:23:33 +0000 (10:23 -0400)
This is an unmodified copy of the `normand.py` module [1], version 0.23.

Normand, an EfficiOS project, is both a template language to generate
binary data and a processor of said language.

`normand.py` is part of the `normand` package, but it's completely
independent, without external dependencies, therefore we can copy it
right here. `normand.py` works with Python 3.4. The module itself offers
a Python 3 API as well as a command-line tool.

We plan to use Normand in Babeltrace 2 to write human-readable and
Git-trackable plain text CTF data streams, and eventually other types of
binary data, for testing purposes.

Although Normand is an EfficiOS project, it's considered an external
project from the point of view of the Babeltrace project. Therefore, we
make Black, isort, and Flake8 ignore it, even though Normand uses the
same settings in its own repository.

[1]: https://github.com/efficios/normand

Signed-off-by: Philippe Proulx <eeppeliteloop@gmail.com>
Change-Id: I6e8937cf40320c3cf1226c3447f71640ed9f91bf
Reviewed-on: https://review.lttng.org/c/babeltrace/+/10910
Tested-by: jenkins <jenkins@lttng.org>
Reviewed-by: Simon Marchi <simon.marchi@efficios.com>
LICENSE
pyproject.toml
setup.cfg
tests/utils/python/normand.py [new file with mode: 0644]

diff --git a/LICENSE b/LICENSE
index 22b5a72e0e21dbf2defa4948a8af9868871f527d..d3b6dcb7965181e7e34903e05428a56e91c27dbf 100644 (file)
--- a/LICENSE
+++ b/LICENSE
@@ -105,6 +105,19 @@ This applies to:
 
        tests/utils/python/typing/typing.py
 
+The Normand Python module is provided under the terms of the
+MIT License:
+
+       SPDX-License-Identifier: MIT
+
+According with:
+
+       LICENSES/MIT
+
+This applies to:
+
+       tests/utils/python/normand.py
+
 
 In addition, other licenses may also apply, see SPDX-License-Identifier in
 individual files.
index 44afa4064c694e4df18c5ddd82252025f0618596..fb033ec7f387586b225e0f14c63fa4215dbc7c53 100644 (file)
@@ -11,6 +11,7 @@
      | src/bindings/python/bt2/setup\.py$
      | src/bindings/python/bt2/bt2/native_bt\.py$
      | src/bindings/python/bt2/bt2/version\.py$
+     | tests/utils/python/normand\.py$
      | tests/utils/python/typing/typing\.py$
      | tests/utils/python/tap
 
@@ -21,6 +22,7 @@
 profile = "black"
 extend_skip_glob = [
     "tests/utils/python/tap",
+    "tests/utils/python/normand.py",
     "tests/utils/python/typing/typing.py",
 ]
 length_sort = true
index 7788df8ac1bba8fcb4c892840c42a0cf1e0fbb3c..fa41210718a22f8cd9313c4a40ca5acbb01c503d 100644 (file)
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,4 +13,4 @@ ignore = E501,W503
 #     Has code to set up the DLL search path before imports.
 per-file-ignores = src/bindings/python/bt2/bt2/__init__.py:F401,E402
 
-exclude = tests/utils/python/tap tests/utils/python/typing/typing.py
+exclude = tests/utils/python/normand.py tests/utils/python/tap tests/utils/python/typing/typing.py
diff --git a/tests/utils/python/normand.py b/tests/utils/python/normand.py
new file mode 100644 (file)
index 0000000..1dd164f
--- /dev/null
@@ -0,0 +1,3036 @@
+# The MIT License (MIT)
+#
+# Copyright (c) 2023 Philippe Proulx <eeppeliteloop@gmail.com>
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+# This module is the portable Normand processor. It offers both the
+# parse() function and the command-line tool (run the module itself)
+# without external dependencies except a `typing` module for Python 3.4.
+#
+# Feel free to copy this module file to your own project to use Normand.
+#
+# Upstream repository: <https://github.com/efficios/normand>.
+
+__author__ = "Philippe Proulx"
+__version__ = "0.23.0"
+__all__ = [
+    "__author__",
+    "__version__",
+    "ByteOrder",
+    "LabelsT",
+    "parse",
+    "ParseError",
+    "ParseErrorMessage",
+    "ParseResult",
+    "TextLocation",
+    "VariablesT",
+]
+
+import re
+import abc
+import ast
+import bz2
+import sys
+import copy
+import enum
+import gzip
+import math
+import base64
+import quopri
+import struct
+import typing
+import functools
+from typing import Any, Set, Dict, List, Union, Pattern, Callable, NoReturn, Optional
+
+
+# Text location (line and column numbers).
+class TextLocation:
+    @classmethod
+    def _create(cls, line_no: int, col_no: int):
+        self = cls.__new__(cls)
+        self._init(line_no, col_no)
+        return self
+
+    def __init__(*args, **kwargs):  # type: ignore
+        raise NotImplementedError
+
+    def _init(self, line_no: int, col_no: int):
+        self._line_no = line_no
+        self._col_no = col_no
+
+    # Line number.
+    @property
+    def line_no(self):
+        return self._line_no
+
+    # Column number.
+    @property
+    def col_no(self):
+        return self._col_no
+
+    def __repr__(self):
+        return "TextLocation({}, {})".format(self._line_no, self._col_no)
+
+
+# Any item.
+class _Item:
+    def __init__(self, text_loc: TextLocation):
+        self._text_loc = text_loc
+
+    # Source text location.
+    @property
+    def text_loc(self):
+        return self._text_loc
+
+
+# Scalar item.
+class _ScalarItem(_Item):
+    # Returns the size, in bytes, of this item.
+    @property
+    @abc.abstractmethod
+    def size(self) -> int:
+        ...
+
+
+# A repeatable item.
+class _RepableItem:
+    pass
+
+
+# Single byte.
+class _Byte(_ScalarItem, _RepableItem):
+    def __init__(self, val: int, text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._val = val
+
+    # Byte value.
+    @property
+    def val(self):
+        return self._val
+
+    @property
+    def size(self):
+        return 1
+
+    def __repr__(self):
+        return "_Byte({}, {})".format(hex(self._val), repr(self._text_loc))
+
+
+# Literal string.
+class _LitStr(_ScalarItem, _RepableItem):
+    def __init__(self, data: bytes, text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._data = data
+
+    # Encoded bytes.
+    @property
+    def data(self):
+        return self._data
+
+    @property
+    def size(self):
+        return len(self._data)
+
+    def __repr__(self):
+        return "_LitStr({}, {})".format(repr(self._data), repr(self._text_loc))
+
+
+# Byte order.
+@enum.unique
+class ByteOrder(enum.Enum):
+    # Big endian.
+    BE = "be"
+
+    # Little endian.
+    LE = "le"
+
+
+# Byte order setting.
+class _SetBo(_Item):
+    def __init__(self, bo: ByteOrder, text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._bo = bo
+
+    @property
+    def bo(self):
+        return self._bo
+
+    def __repr__(self):
+        return "_SetBo({}, {})".format(repr(self._bo), repr(self._text_loc))
+
+
+# Label.
+class _Label(_Item):
+    def __init__(self, name: str, text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._name = name
+
+    # Label name.
+    @property
+    def name(self):
+        return self._name
+
+    def __repr__(self):
+        return "_Label({}, {})".format(repr(self._name), repr(self._text_loc))
+
+
+# Offset setting.
+class _SetOffset(_Item):
+    def __init__(self, val: int, text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._val = val
+
+    # Offset value (bytes).
+    @property
+    def val(self):
+        return self._val
+
+    def __repr__(self):
+        return "_SetOffset({}, {})".format(repr(self._val), repr(self._text_loc))
+
+
+# Offset alignment.
+class _AlignOffset(_Item):
+    def __init__(self, val: int, pad_val: int, text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._val = val
+        self._pad_val = pad_val
+
+    # Alignment value (bits).
+    @property
+    def val(self):
+        return self._val
+
+    # Padding byte value.
+    @property
+    def pad_val(self):
+        return self._pad_val
+
+    def __repr__(self):
+        return "_AlignOffset({}, {}, {})".format(
+            repr(self._val), repr(self._pad_val), repr(self._text_loc)
+        )
+
+
+# Mixin of containing an AST expression and its string.
+class _ExprMixin:
+    def __init__(self, expr_str: str, expr: ast.Expression):
+        self._expr_str = expr_str
+        self._expr = expr
+
+    # Expression string.
+    @property
+    def expr_str(self):
+        return self._expr_str
+
+    # Expression node to evaluate.
+    @property
+    def expr(self):
+        return self._expr
+
+
+# Fill until some offset.
+class _FillUntil(_Item, _ExprMixin):
+    def __init__(
+        self, expr_str: str, expr: ast.Expression, pad_val: int, text_loc: TextLocation
+    ):
+        super().__init__(text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+        self._pad_val = pad_val
+
+    # Padding byte value.
+    @property
+    def pad_val(self):
+        return self._pad_val
+
+    def __repr__(self):
+        return "_FillUntil({}, {}, {}, {})".format(
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._pad_val),
+            repr(self._text_loc),
+        )
+
+
+# Variable assignment.
+class _VarAssign(_Item, _ExprMixin):
+    def __init__(
+        self, name: str, expr_str: str, expr: ast.Expression, text_loc: TextLocation
+    ):
+        super().__init__(text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+        self._name = name
+
+    # Name.
+    @property
+    def name(self):
+        return self._name
+
+    def __repr__(self):
+        return "_VarAssign({}, {}, {}, {})".format(
+            repr(self._name),
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._text_loc),
+        )
+
+
+# Fixed-length number, possibly needing more than one byte.
+class _FlNum(_ScalarItem, _RepableItem, _ExprMixin):
+    def __init__(
+        self,
+        expr_str: str,
+        expr: ast.Expression,
+        len: int,
+        bo: Optional[ByteOrder],
+        text_loc: TextLocation,
+    ):
+        super().__init__(text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+        self._len = len
+        self._bo = bo
+
+    # Length (bits).
+    @property
+    def len(self):
+        return self._len
+
+    # Byte order override.
+    @property
+    def bo(self):
+        return self._bo
+
+    @property
+    def size(self):
+        return self._len // 8
+
+    def __repr__(self):
+        return "_FlNum({}, {}, {}, {}, {})".format(
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._len),
+            repr(self._bo),
+            repr(self._text_loc),
+        )
+
+
+# LEB128 integer.
+class _Leb128Int(_Item, _RepableItem, _ExprMixin):
+    def __init__(self, expr_str: str, expr: ast.Expression, text_loc: TextLocation):
+        super().__init__(text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+
+    def __repr__(self):
+        return "{}({}, {}, {})".format(
+            self.__class__.__name__,
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._text_loc),
+        )
+
+
+# Unsigned LEB128 integer.
+class _ULeb128Int(_Leb128Int, _RepableItem, _ExprMixin):
+    pass
+
+
+# Signed LEB128 integer.
+class _SLeb128Int(_Leb128Int, _RepableItem, _ExprMixin):
+    pass
+
+
+# String.
+class _Str(_Item, _RepableItem, _ExprMixin):
+    def __init__(
+        self, expr_str: str, expr: ast.Expression, codec: str, text_loc: TextLocation
+    ):
+        super().__init__(text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+        self._codec = codec
+
+    # Codec name.
+    @property
+    def codec(self):
+        return self._codec
+
+    def __repr__(self):
+        return "_Str({}, {}, {}, {})".format(
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._codec),
+            repr(self._text_loc),
+        )
+
+
+# Group of items.
+class _Group(_Item, _RepableItem):
+    def __init__(self, items: List[_Item], text_loc: TextLocation):
+        super().__init__(text_loc)
+        self._items = items
+
+    # Contained items.
+    @property
+    def items(self):
+        return self._items
+
+    def __repr__(self):
+        return "_Group({}, {})".format(repr(self._items), repr(self._text_loc))
+
+
+# Repetition item.
+class _Rep(_Group, _ExprMixin):
+    def __init__(
+        self,
+        items: List[_Item],
+        expr_str: str,
+        expr: ast.Expression,
+        text_loc: TextLocation,
+    ):
+        super().__init__(items, text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+
+    def __repr__(self):
+        return "_Rep({}, {}, {}, {})".format(
+            repr(self._items),
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._text_loc),
+        )
+
+
+# Conditional item.
+class _Cond(_Item, _ExprMixin):
+    def __init__(
+        self,
+        true_item: _Group,
+        false_item: _Group,
+        expr_str: str,
+        expr: ast.Expression,
+        text_loc: TextLocation,
+    ):
+        super().__init__(text_loc)
+        _ExprMixin.__init__(self, expr_str, expr)
+        self._true_item = true_item
+        self._false_item = false_item
+
+    # Item when condition is true.
+    @property
+    def true_item(self):
+        return self._true_item
+
+    # Item when condition is false.
+    @property
+    def false_item(self):
+        return self._false_item
+
+    def __repr__(self):
+        return "_Cond({}, {}, {}, {}, {})".format(
+            repr(self._true_item),
+            repr(self._false_item),
+            repr(self._expr_str),
+            repr(self._expr),
+            repr(self._text_loc),
+        )
+
+
+# Transformation.
+class _Trans(_Group, _RepableItem):
+    def __init__(
+        self,
+        items: List[_Item],
+        name: str,
+        func: Callable[[Union[bytes, bytearray]], bytes],
+        text_loc: TextLocation,
+    ):
+        super().__init__(items, text_loc)
+        self._name = name
+        self._func = func
+
+    @property
+    def name(self):
+        return self._name
+
+    # Transforms the data `data`.
+    def trans(self, data: Union[bytes, bytearray]):
+        return self._func(data)
+
+    def __repr__(self):
+        return "_Trans({}, {}, {}, {})".format(
+            repr(self._items),
+            repr(self._name),
+            repr(self._func),
+            repr(self._text_loc),
+        )
+
+
+# Macro definition item.
+class _MacroDef(_Group):
+    def __init__(
+        self,
+        name: str,
+        param_names: List[str],
+        items: List[_Item],
+        text_loc: TextLocation,
+    ):
+        super().__init__(items, text_loc)
+        self._name = name
+        self._param_names = param_names
+
+    # Name.
+    @property
+    def name(self):
+        return self._name
+
+    # Parameters.
+    @property
+    def param_names(self):
+        return self._param_names
+
+    def __repr__(self):
+        return "_MacroDef({}, {}, {}, {})".format(
+            repr(self._name),
+            repr(self._param_names),
+            repr(self._items),
+            repr(self._text_loc),
+        )
+
+
+# Macro expansion parameter.
+class _MacroExpParam:
+    def __init__(self, expr_str: str, expr: ast.Expression, text_loc: TextLocation):
+        self._expr_str = expr_str
+        self._expr = expr
+        self._text_loc = text_loc
+
+    # Expression string.
+    @property
+    def expr_str(self):
+        return self._expr_str
+
+    # Expression.
+    @property
+    def expr(self):
+        return self._expr
+
+    # Source text location.
+    @property
+    def text_loc(self):
+        return self._text_loc
+
+    def __repr__(self):
+        return "_MacroExpParam({}, {}, {})".format(
+            repr(self._expr_str), repr(self._expr), repr(self._text_loc)
+        )
+
+
+# Macro expansion item.
+class _MacroExp(_Item, _RepableItem):
+    def __init__(
+        self,
+        name: str,
+        params: List[_MacroExpParam],
+        text_loc: TextLocation,
+    ):
+        super().__init__(text_loc)
+        self._name = name
+        self._params = params
+
+    # Name.
+    @property
+    def name(self):
+        return self._name
+
+    # Parameters.
+    @property
+    def params(self):
+        return self._params
+
+    def __repr__(self):
+        return "_MacroExp({}, {}, {})".format(
+            repr(self._name),
+            repr(self._params),
+            repr(self._text_loc),
+        )
+
+
+# A parsing error message: a string and a text location.
+class ParseErrorMessage:
+    @classmethod
+    def _create(cls, text: str, text_loc: TextLocation):
+        self = cls.__new__(cls)
+        self._init(text, text_loc)
+        return self
+
+    def __init__(self, *args, **kwargs):  # type: ignore
+        raise NotImplementedError
+
+    def _init(self, text: str, text_loc: TextLocation):
+        self._text = text
+        self._text_loc = text_loc
+
+    # Message text.
+    @property
+    def text(self):
+        return self._text
+
+    # Source text location.
+    @property
+    def text_location(self):
+        return self._text_loc
+
+
+# A parsing error containing one or more messages (`ParseErrorMessage`).
+class ParseError(RuntimeError):
+    @classmethod
+    def _create(cls, msg: str, text_loc: TextLocation):
+        self = cls.__new__(cls)
+        self._init(msg, text_loc)
+        return self
+
+    def __init__(self, *args, **kwargs):  # type: ignore
+        raise NotImplementedError
+
+    def _init(self, msg: str, text_loc: TextLocation):
+        super().__init__(msg)
+        self._msgs = []  # type: List[ParseErrorMessage]
+        self._add_msg(msg, text_loc)
+
+    def _add_msg(self, msg: str, text_loc: TextLocation):
+        self._msgs.append(
+            ParseErrorMessage._create(  # pyright: ignore[reportPrivateUsage]
+                msg, text_loc
+            )
+        )
+
+    # Parsing error messages.
+    #
+    # The first message is the most specific one.
+    @property
+    def messages(self):
+        return self._msgs
+
+
+# Raises a parsing error, forwarding the parameters to the constructor.
+def _raise_error(msg: str, text_loc: TextLocation) -> NoReturn:
+    raise ParseError._create(msg, text_loc)  # pyright: ignore[reportPrivateUsage]
+
+
+# Adds a message to the parsing error `exc`.
+def _add_error_msg(exc: ParseError, msg: str, text_loc: TextLocation):
+    exc._add_msg(msg, text_loc)  # pyright: ignore[reportPrivateUsage]
+
+
+# Appends a message to the parsing error `exc` and reraises it.
+def _augment_error(exc: ParseError, msg: str, text_loc: TextLocation) -> NoReturn:
+    _add_error_msg(exc, msg, text_loc)
+    raise exc
+
+
+# Returns a normalized version (so as to be parseable by int()) of
+# the constant integer string `s`, possibly negative, dealing with
+# any radix suffix.
+def _norm_const_int(s: str):
+    neg = ""
+    pos = s
+
+    if s.startswith("-"):
+        neg = "-"
+        pos = s[1:]
+
+    for r in "xXoObB":
+        if pos.startswith("0" + r):
+            # Already correct
+            return s
+
+    # Try suffix
+    asm_suf_base = {
+        "h": "x",
+        "H": "x",
+        "q": "o",
+        "Q": "o",
+        "o": "o",
+        "O": "o",
+        "b": "b",
+        "B": "B",
+    }
+
+    for suf in asm_suf_base:
+        if pos[-1] == suf:
+            s = "{}0{}{}".format(neg, asm_suf_base[suf], pos.rstrip(suf))
+
+    return s
+
+
+# Encodes the string `s` using the codec `codec`, raising `ParseError`
+# with `text_loc` on encoding error.
+def _encode_str(s: str, codec: str, text_loc: TextLocation):
+    try:
+        return s.encode(codec)
+    except UnicodeEncodeError:
+        _raise_error(
+            "Cannot encode `{}` with the `{}` encoding".format(s, codec), text_loc
+        )
+
+
+# Variables dictionary type (for type hints).
+VariablesT = Dict[str, Union[int, float, str]]
+
+
+# Labels dictionary type (for type hints).
+LabelsT = Dict[str, int]
+
+
+# Common patterns.
+_py_name_pat = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
+_pos_const_int_pat = re.compile(
+    r"(?:0[Xx][A-Fa-f0-9]+|0[Oo][0-7]+|0[Bb][01]+|[A-Fa-f0-9]+[hH]|[0-7]+[qQoO]|[01]+[bB]|\d+)\b"
+)
+_const_int_pat = re.compile(r"(?P<neg>-)?(?:{})".format(_pos_const_int_pat.pattern))
+_const_float_pat = re.compile(
+    r"[-+]?(?:(?:\d*\.\d+)|(?:\d+\.))(?:[Ee][+-]?\d+)?(?=\W|)"
+)
+
+
+# Macro definition dictionary.
+_MacroDefsT = Dict[str, _MacroDef]
+
+
+# Normand parser.
+#
+# The constructor accepts a Normand input. After building, use the `res`
+# property to get the resulting main group.
+class _Parser:
+    # Builds a parser to parse the Normand input `normand`, parsing
+    # immediately.
+    def __init__(self, normand: str, variables: VariablesT, labels: LabelsT):
+        self._normand = normand
+        self._at = 0
+        self._line_no = 1
+        self._col_no = 1
+        self._label_names = set(labels.keys())
+        self._var_names = set(variables.keys())
+        self._macro_defs = {}  # type: _MacroDefsT
+        self._base_item_parse_funcs = [
+            self._try_parse_byte,
+            self._try_parse_str,
+            self._try_parse_val,
+            self._try_parse_var_assign,
+            self._try_parse_set_bo,
+            self._try_parse_label_or_set_offset,
+            self._try_parse_align_offset,
+            self._try_parse_fill_until,
+            self._try_parse_group,
+            self._try_parse_rep_block,
+            self._try_parse_cond_block,
+            self._try_parse_macro_exp,
+            self._try_parse_trans_block,
+        ]
+        self._parse()
+
+    # Result (main group).
+    @property
+    def res(self):
+        return self._res
+
+    # Macro definitions.
+    @property
+    def macro_defs(self):
+        return self._macro_defs
+
+    # Current text location.
+    @property
+    def _text_loc(self):
+        return TextLocation._create(  # pyright: ignore[reportPrivateUsage]
+            self._line_no, self._col_no
+        )
+
+    # Returns `True` if this parser is done parsing.
+    def _is_done(self):
+        return self._at == len(self._normand)
+
+    # Returns `True` if this parser isn't done parsing.
+    def _isnt_done(self):
+        return not self._is_done()
+
+    # Raises a parse error, creating it using the message `msg` and the
+    # current text location.
+    def _raise_error(self, msg: str) -> NoReturn:
+        _raise_error(msg, self._text_loc)
+
+    # Tries to make the pattern `pat` match the current substring,
+    # returning the match object and updating `self._at`,
+    # `self._line_no`, and `self._col_no` on success.
+    def _try_parse_pat(self, pat: Pattern[str]):
+        m = pat.match(self._normand, self._at)
+
+        if m is None:
+            return
+
+        # Skip matched string
+        self._at += len(m.group(0))
+
+        # Update line number
+        self._line_no += m.group(0).count("\n")
+
+        # Update column number
+        for i in reversed(range(self._at)):
+            if self._normand[i] == "\n" or i == 0:
+                if i == 0:
+                    self._col_no = self._at + 1
+                else:
+                    self._col_no = self._at - i
+
+                break
+
+        # Return match object
+        return m
+
+    # Expects the pattern `pat` to match the current substring,
+    # returning the match object and updating `self._at`,
+    # `self._line_no`, and `self._col_no` on success, or raising a parse
+    # error with the message `error_msg` on error.
+    def _expect_pat(self, pat: Pattern[str], error_msg: str):
+        # Match
+        m = self._try_parse_pat(pat)
+
+        if m is None:
+            # No match: error
+            self._raise_error(error_msg)
+
+        # Return match object
+        return m
+
+    # Patterns for _skip_*()
+    _comment_pat = re.compile(r"#[^#]*?(?:$|#)", re.M)
+    _ws_or_comments_pat = re.compile(r"(?:\s|{})*".format(_comment_pat.pattern), re.M)
+    _ws_or_syms_or_comments_pat = re.compile(
+        r"(?:[\s/\\?&:;.,_=|-]|{})*".format(_comment_pat.pattern), re.M
+    )
+
+    # Skips as many whitespaces and comments as possible, but not
+    # insignificant symbol characters.
+    def _skip_ws_and_comments(self):
+        self._try_parse_pat(self._ws_or_comments_pat)
+
+    # Skips as many whitespaces, insignificant symbol characters, and
+    # comments as possible.
+    def _skip_ws_and_comments_and_syms(self):
+        self._try_parse_pat(self._ws_or_syms_or_comments_pat)
+
+    # Pattern for _try_parse_hex_byte()
+    _nibble_pat = re.compile(r"[A-Fa-f0-9]")
+
+    # Tries to parse a hexadecimal byte, returning a byte item on
+    # success.
+    def _try_parse_hex_byte(self):
+        begin_text_loc = self._text_loc
+
+        # Match initial nibble
+        m_high = self._try_parse_pat(self._nibble_pat)
+
+        if m_high is None:
+            # No match
+            return
+
+        # Expect another nibble
+        self._skip_ws_and_comments_and_syms()
+        m_low = self._expect_pat(
+            self._nibble_pat, "Expecting another hexadecimal nibble"
+        )
+
+        # Return item
+        return _Byte(int(m_high.group(0) + m_low.group(0), 16), begin_text_loc)
+
+    # Patterns for _try_parse_bin_byte()
+    _bin_byte_bit_pat = re.compile(r"[01]")
+    _bin_byte_prefix_pat = re.compile(r"%+")
+
+    # Tries to parse a binary byte, returning a byte item on success.
+    def _try_parse_bin_byte(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        m = self._try_parse_pat(self._bin_byte_prefix_pat)
+
+        if m is None:
+            # No match
+            return
+
+        # Expect as many bytes as there are `%` prefixes
+        items = []  # type: List[_Item]
+
+        for _ in range(len(m.group(0))):
+            self._skip_ws_and_comments_and_syms()
+            byte_text_loc = self._text_loc
+            bits = []  # type: List[str]
+
+            # Expect eight bits
+            for _ in range(8):
+                self._skip_ws_and_comments_and_syms()
+                m = self._expect_pat(
+                    self._bin_byte_bit_pat, "Expecting a bit (`0` or `1`)"
+                )
+                bits.append(m.group(0))
+
+            items.append(_Byte(int("".join(bits), 2), byte_text_loc))
+
+        # Return item
+        if len(items) == 1:
+            return items[0]
+
+        # As group
+        return _Group(items, begin_text_loc)
+
+    # Patterns for _try_parse_dec_byte()
+    _dec_byte_prefix_pat = re.compile(r"\$")
+    _dec_byte_val_pat = re.compile(r"(?P<neg>-?)(?P<val>\d+)")
+
+    # Tries to parse a decimal byte, returning a byte item on success.
+    def _try_parse_dec_byte(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._dec_byte_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect the value
+        self._skip_ws_and_comments()
+        m = self._expect_pat(self._dec_byte_val_pat, "Expecting a decimal constant")
+
+        # Compute value
+        val = int(m.group("val")) * (-1 if m.group("neg") == "-" else 1)
+
+        # Validate
+        if val < -128 or val > 255:
+            _raise_error("Invalid decimal byte value {}".format(val), begin_text_loc)
+
+        # Two's complement
+        val %= 256
+
+        # Return item
+        return _Byte(val, begin_text_loc)
+
+    # Tries to parse a byte, returning a byte item on success.
+    def _try_parse_byte(self):
+        # Hexadecimal
+        item = self._try_parse_hex_byte()
+
+        if item is not None:
+            return item
+
+        # Binary
+        item = self._try_parse_bin_byte()
+
+        if item is not None:
+            return item
+
+        # Decimal
+        item = self._try_parse_dec_byte()
+
+        if item is not None:
+            return item
+
+    # Strings corresponding to escape sequence characters
+    _lit_str_escape_seq_strs = {
+        "0": "\0",
+        "a": "\a",
+        "b": "\b",
+        "e": "\x1b",
+        "f": "\f",
+        "n": "\n",
+        "r": "\r",
+        "t": "\t",
+        "v": "\v",
+        "\\": "\\",
+        '"': '"',
+    }
+
+    # Patterns for _try_parse_lit_str()
+    _lit_str_prefix_suffix_pat = re.compile(r'"')
+    _lit_str_contents_pat = re.compile(r'(?:(?:\\.)|[^"])*')
+
+    # Parses a literal string between double quotes (without an encoding
+    # prefix) and returns the resulting string.
+    def _try_parse_lit_str(self, with_prefix: bool):
+        # Match prefix if needed
+        if with_prefix:
+            if self._try_parse_pat(self._lit_str_prefix_suffix_pat) is None:
+                # No match
+                return
+
+        # Expect literal string
+        m = self._expect_pat(self._lit_str_contents_pat, "Expecting a literal string")
+
+        # Expect end of string
+        self._expect_pat(
+            self._lit_str_prefix_suffix_pat, 'Expecting `"` (end of literal string)'
+        )
+
+        # Replace escape sequences
+        val = m.group(0)
+
+        for ec in '0abefnrtv"\\':
+            val = val.replace(r"\{}".format(ec), self._lit_str_escape_seq_strs[ec])
+
+        # Return string
+        return val
+
+    # Patterns for _try_parse_utf_str_encoding()
+    _str_encoding_utf_prefix_pat = re.compile(r"u")
+    _str_encoding_utf_pat = re.compile(r"(?:8|(?:(?:16|32)(?:[bl]e)))\b")
+
+    # Tries to parse a UTF encoding specification, returning the Python
+    # codec name on success.
+    def _try_parse_utf_str_encoding(self):
+        # Match prefix
+        if self._try_parse_pat(self._str_encoding_utf_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect UTF specification
+        m = self._expect_pat(
+            self._str_encoding_utf_pat,
+            "Expecting `8`, `16be`, `16le`, `32be` or `32le`",
+        )
+
+        # Convert to codec name
+        return {
+            "8": "utf_8",
+            "16be": "utf_16_be",
+            "16le": "utf_16_le",
+            "32be": "utf_32_be",
+            "32le": "utf_32_le",
+        }[m.group(0)]
+
+    # Patterns for _try_parse_str_encoding()
+    _str_encoding_gen_prefix_pat = re.compile(r"s")
+    _str_encoding_colon_pat = re.compile(r":")
+    _str_encoding_non_utf_pat = re.compile(r"latin(?:[1-9]|10)\b")
+
+    # Tries to parse a string encoding specification, returning the
+    # Python codec name on success.
+    #
+    # Requires the general prefix (`s:`) if `req_gen_prefix` is `True`.
+    def _try_parse_str_encoding(self, req_gen_prefix: bool = False):
+        # General prefix?
+        if self._try_parse_pat(self._str_encoding_gen_prefix_pat) is not None:
+            # Expect `:`
+            self._skip_ws_and_comments()
+            self._expect_pat(self._str_encoding_colon_pat, "Expecting `:`")
+
+            # Expect encoding specification
+            self._skip_ws_and_comments()
+
+            # UTF?
+            codec = self._try_parse_utf_str_encoding()
+
+            if codec is not None:
+                return codec
+
+            # Expect Latin
+            m = self._expect_pat(
+                self._str_encoding_non_utf_pat,
+                "Expecting `u8`, `u16be`, `u16le`, `u32be`, `u32le`, or `latin1` to `latin10`",
+            )
+            return m.group(0)
+
+        # UTF?
+        if not req_gen_prefix:
+            return self._try_parse_utf_str_encoding()
+
+    # Patterns for _try_parse_str()
+    _lit_str_prefix_pat = re.compile(r'"')
+    _str_prefix_pat = re.compile(r'"|\{')
+    _str_expr_pat = re.compile(r"[^}]+")
+    _str_expr_suffix_pat = re.compile(r"\}")
+
+    # Tries to parse a string, returning a literal string or string item
+    # on success.
+    def _try_parse_str(self):
+        begin_text_loc = self._text_loc
+
+        # Encoding
+        codec = self._try_parse_str_encoding()
+
+        # Match prefix (expect if there's an encoding specification)
+        self._skip_ws_and_comments()
+
+        if codec is None:
+            # No encoding: only a literal string (UTF-8) is legal
+            m_prefix = self._try_parse_pat(self._lit_str_prefix_pat)
+
+            if m_prefix is None:
+                return
+        else:
+            # Encoding present: expect a string prefix
+            m_prefix = self._expect_pat(self._str_prefix_pat, 'Expecting `"` or `{`')
+
+        # Literal string or expression?
+        prefix = m_prefix.group(0)
+
+        if prefix == '"':
+            # Expect literal string
+            str_text_loc = self._text_loc
+            val = self._try_parse_lit_str(False)
+
+            if val is None:
+                self._raise_error("Expecting a literal string")
+
+            # Encode string
+            data = _encode_str(val, "utf_8" if codec is None else codec, str_text_loc)
+
+            # Return item
+            return _LitStr(data, begin_text_loc)
+        else:
+            # Expect expression
+            self._skip_ws_and_comments()
+            expr_text_loc = self._text_loc
+            m = self._expect_pat(self._str_expr_pat, "Expecting an expression")
+
+            # Expect `}`
+            self._expect_pat(self._str_expr_suffix_pat, "Expecting `}`")
+
+            # Create an expression node from the expression string
+            expr_str, expr = self._ast_expr_from_str(m.group(0), expr_text_loc)
+
+            # Return item
+            assert codec is not None
+            return _Str(expr_str, expr, codec, begin_text_loc)
+
+    # Common right parenthesis pattern
+    _right_paren_pat = re.compile(r"\)")
+
+    # Patterns for _try_parse_group()
+    _group_prefix_pat = re.compile(r"\(|!g(?:roup)?\b")
+
+    # Tries to parse a group, returning a group item on success.
+    def _try_parse_group(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        m_open = self._try_parse_pat(self._group_prefix_pat)
+
+        if m_open is None:
+            # No match
+            return
+
+        # Parse items
+        items = self._parse_items()
+
+        # Expect end of group
+        self._skip_ws_and_comments_and_syms()
+
+        if m_open.group(0) == "(":
+            pat = self._right_paren_pat
+            exp = ")"
+        else:
+            pat = self._block_end_pat
+            exp = "!end"
+
+        self._expect_pat(pat, "Expecting an item or `{}` (end of group)".format(exp))
+
+        # Return item
+        return _Group(items, begin_text_loc)
+
+    # Returns a stripped expression string and an AST expression node
+    # from the expression string `expr_str` at text location `text_loc`.
+    def _ast_expr_from_str(self, expr_str: str, text_loc: TextLocation):
+        # Create an expression node from the expression string
+        expr_str = expr_str.strip().replace("\n", " ")
+
+        try:
+            expr = ast.parse(expr_str, mode="eval")
+        except SyntaxError:
+            _raise_error(
+                "Invalid expression `{}`: invalid syntax".format(expr_str),
+                text_loc,
+            )
+
+        return expr_str, expr
+
+    # Returns a `ByteOrder` value from the _valid_ byte order string
+    # `bo_str`.
+    @staticmethod
+    def _bo_from_str(bo_str: str):
+        return {
+            "be": ByteOrder.BE,
+            "le": ByteOrder.LE,
+        }[bo_str]
+
+    # Patterns for _try_parse_val()
+    _val_prefix_pat = re.compile(r"\[")
+    _val_expr_pat = re.compile(r"([^\]:]+):")
+    _fl_num_len_fmt_pat = re.compile(r"(?P<len>8|16|24|32|40|48|56|64)(?P<bo>[bl]e)?")
+    _leb128_int_fmt_pat = re.compile(r"(u|s)leb128")
+    _val_suffix_pat = re.compile(r"]")
+
+    # Tries to parse a value (number or string) and format (fixed length
+    # in bits and optional byte order override, `uleb128`, `sleb128`, or
+    # `s:` followed with an encoding name), returning an item on
+    # success.
+    def _try_parse_val(self):
+        # Match prefix
+        if self._try_parse_pat(self._val_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect expression and `:`
+        self._skip_ws_and_comments()
+        expr_text_loc = self._text_loc
+        m = self._expect_pat(self._val_expr_pat, "Expecting an expression")
+
+        # Create an expression node from the expression string
+        expr_str, expr = self._ast_expr_from_str(m.group(1), expr_text_loc)
+
+        # Fixed length?
+        self._skip_ws_and_comments()
+        m_fmt = self._try_parse_pat(self._fl_num_len_fmt_pat)
+
+        if m_fmt is not None:
+            # Byte order override
+            if m_fmt.group("bo") is None:
+                bo = None
+            else:
+                bo = self._bo_from_str(m_fmt.group("bo"))
+
+            # Create fixed-length number item
+            item = _FlNum(
+                expr_str,
+                expr,
+                int(m_fmt.group("len")),
+                bo,
+                expr_text_loc,
+            )
+        else:
+            # LEB128?
+            m_fmt = self._try_parse_pat(self._leb128_int_fmt_pat)
+
+            if m_fmt is not None:
+                # Create LEB128 integer item
+                cls = _ULeb128Int if m_fmt.group(1) == "u" else _SLeb128Int
+                item = cls(expr_str, expr, expr_text_loc)
+            else:
+                # String encoding?
+                codec = self._try_parse_str_encoding(True)
+
+                if codec is not None:
+                    # Create string item
+                    item = _Str(expr_str, expr, codec, expr_text_loc)
+                else:
+                    # At this point it's invalid
+                    self._raise_error(
+                        "Expecting a fixed length (multiple of eight bits and optional `be` or `le`), `uleb128`, `sleb128`, or `s:` followed with a valid encoding (`u8`, `u16be`, `u16le`, `u32be`, `u32le`, or `latin1` to `latin10`)"
+                    )
+
+        # Expect `]`
+        self._skip_ws_and_comments()
+        m = self._expect_pat(self._val_suffix_pat, "Expecting `]`")
+
+        # Return item
+        return item
+
+    # Patterns for _try_parse_var_assign()
+    _var_assign_prefix_pat = re.compile(r"\{")
+    _var_assign_equal_pat = re.compile(r"=")
+    _var_assign_expr_pat = re.compile(r"[^}]+")
+    _var_assign_suffix_pat = re.compile(r"\}")
+
+    # Tries to parse a variable assignment, returning a variable
+    # assignment item on success.
+    def _try_parse_var_assign(self):
+        # Match prefix
+        if self._try_parse_pat(self._var_assign_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect a name
+        self._skip_ws_and_comments()
+        name_text_loc = self._text_loc
+        m = self._expect_pat(_py_name_pat, "Expecting a valid Python name")
+        name = m.group(0)
+
+        # Expect `=`
+        self._skip_ws_and_comments()
+        self._expect_pat(self._var_assign_equal_pat, "Expecting `=`")
+
+        # Expect expression
+        self._skip_ws_and_comments()
+        expr_text_loc = self._text_loc
+        m_expr = self._expect_pat(self._var_assign_expr_pat, "Expecting an expression")
+
+        # Expect `}`
+        self._skip_ws_and_comments()
+        self._expect_pat(self._var_assign_suffix_pat, "Expecting `}`")
+
+        # Validate name
+        if name == _icitte_name:
+            _raise_error(
+                "`{}` is a reserved variable name".format(_icitte_name), name_text_loc
+            )
+
+        if name in self._label_names:
+            _raise_error("Existing label named `{}`".format(name), name_text_loc)
+
+        # Create an expression node from the expression string
+        expr_str, expr = self._ast_expr_from_str(m_expr.group(0), expr_text_loc)
+
+        # Add to known variable names
+        self._var_names.add(name)
+
+        # Return item
+        return _VarAssign(
+            name,
+            expr_str,
+            expr,
+            name_text_loc,
+        )
+
+    # Pattern for _try_parse_set_bo()
+    _set_bo_pat = re.compile(r"!([bl]e)\b")
+
+    # Tries to parse a byte order setting, returning a byte order
+    # setting item on success.
+    def _try_parse_set_bo(self):
+        begin_text_loc = self._text_loc
+
+        # Match
+        m = self._try_parse_pat(self._set_bo_pat)
+
+        if m is None:
+            # No match
+            return
+
+        # Return corresponding item
+        if m.group(1) == "be":
+            bo = ByteOrder.BE
+        else:
+            assert m.group(1) == "le"
+            bo = ByteOrder.LE
+
+        return _SetBo(bo, begin_text_loc)
+
+    # Tries to parse an offset setting value (after the initial `<`),
+    # returning an offset item on success.
+    def _try_parse_set_offset_val(self):
+        begin_text_loc = self._text_loc
+
+        # Match
+        m = self._try_parse_pat(_pos_const_int_pat)
+
+        if m is None:
+            # No match
+            return
+
+        # Return item
+        return _SetOffset(int(_norm_const_int(m.group(0)), 0), begin_text_loc)
+
+    # Tries to parse a label name (after the initial `<`), returning a
+    # label item on success.
+    def _try_parse_label_name(self):
+        begin_text_loc = self._text_loc
+
+        # Match
+        m = self._try_parse_pat(_py_name_pat)
+
+        if m is None:
+            # No match
+            return
+
+        # Validate
+        name = m.group(0)
+
+        if name == _icitte_name:
+            _raise_error(
+                "`{}` is a reserved label name".format(_icitte_name), begin_text_loc
+            )
+
+        if name in self._label_names:
+            _raise_error("Duplicate label name `{}`".format(name), begin_text_loc)
+
+        if name in self._var_names:
+            _raise_error("Existing variable named `{}`".format(name), begin_text_loc)
+
+        # Add to known label names
+        self._label_names.add(name)
+
+        # Return item
+        return _Label(name, begin_text_loc)
+
+    # Patterns for _try_parse_label_or_set_offset()
+    _label_set_offset_prefix_pat = re.compile(r"<")
+    _label_set_offset_suffix_pat = re.compile(r">")
+
+    # Tries to parse a label or an offset setting, returning an item on
+    # success.
+    def _try_parse_label_or_set_offset(self):
+        # Match prefix
+        if self._try_parse_pat(self._label_set_offset_prefix_pat) is None:
+            # No match
+            return
+
+        # Offset setting item?
+        self._skip_ws_and_comments()
+        item = self._try_parse_set_offset_val()
+
+        if item is None:
+            # Label item?
+            item = self._try_parse_label_name()
+
+            if item is None:
+                # At this point it's invalid
+                self._raise_error("Expecting a label name or an offset setting value")
+
+        # Expect suffix
+        self._skip_ws_and_comments()
+        self._expect_pat(self._label_set_offset_suffix_pat, "Expecting `>`")
+        return item
+
+    # Pattern for _parse_pad_val()
+    _pad_val_prefix_pat = re.compile(r"~")
+
+    # Tries to parse a padding value, returning the padding value, or 0
+    # if none.
+    def _parse_pad_val(self):
+        # Padding value?
+        self._skip_ws_and_comments()
+        pad_val = 0
+
+        if self._try_parse_pat(self._pad_val_prefix_pat) is not None:
+            self._skip_ws_and_comments()
+            pad_val_text_loc = self._text_loc
+            m = self._expect_pat(
+                _pos_const_int_pat,
+                "Expecting a positive constant integer (byte value)",
+            )
+
+            # Validate
+            pad_val = int(_norm_const_int(m.group(0)), 0)
+
+            if pad_val > 255:
+                _raise_error(
+                    "Invalid padding byte value {}".format(pad_val),
+                    pad_val_text_loc,
+                )
+
+        return pad_val
+
+    # Patterns for _try_parse_align_offset()
+    _align_offset_prefix_pat = re.compile(r"@")
+    _align_offset_val_pat = re.compile(r"\d+")
+
+    # Tries to parse an offset alignment, returning an offset alignment
+    # item on success.
+    def _try_parse_align_offset(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._align_offset_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect an alignment
+        self._skip_ws_and_comments()
+        align_text_loc = self._text_loc
+        m = self._expect_pat(
+            self._align_offset_val_pat,
+            "Expecting an alignment (positive multiple of eight bits)",
+        )
+
+        # Validate alignment
+        val = int(m.group(0))
+
+        if val <= 0 or (val % 8) != 0:
+            _raise_error(
+                "Invalid alignment value {} (not a positive multiple of eight)".format(
+                    val
+                ),
+                align_text_loc,
+            )
+
+        # Padding value
+        pad_val = self._parse_pad_val()
+
+        # Return item
+        return _AlignOffset(val, pad_val, begin_text_loc)
+
+    # Patterns for _expect_expr()
+    _inner_expr_prefix_pat = re.compile(r"\{")
+    _inner_expr_pat = re.compile(r"[^}]+")
+    _inner_expr_suffix_pat = re.compile(r"\}")
+
+    # Parses an expression outside a `{`/`}` context.
+    #
+    # This function accepts:
+    #
+    # â€¢ A Python expression within `{` and `}`.
+    #
+    # â€¢ A Python name.
+    #
+    # â€¢ If `accept_const_int` is `True`: a constant integer, which may
+    #   be negative if `allow_neg_int` is `True`.
+    #
+    # â€¢ If `accept_float` is `True`: a constant floating point number.
+    #
+    # Returns the stripped expression string and AST expression.
+    def _expect_expr(
+        self,
+        accept_const_int: bool = False,
+        allow_neg_int: bool = False,
+        accept_const_float: bool = False,
+        accept_lit_str: bool = False,
+    ):
+        begin_text_loc = self._text_loc
+
+        # Constant floating point number?
+        if accept_const_float:
+            m = self._try_parse_pat(_const_float_pat)
+
+            if m is not None:
+                return self._ast_expr_from_str(m.group(0), begin_text_loc)
+
+        # Constant integer?
+        if accept_const_int:
+            m = self._try_parse_pat(_const_int_pat)
+
+            if m is not None:
+                # Negative and allowed?
+                if m.group("neg") == "-" and not allow_neg_int:
+                    _raise_error(
+                        "Expecting a positive constant integer", begin_text_loc
+                    )
+
+                expr_str = _norm_const_int(m.group(0))
+                return self._ast_expr_from_str(expr_str, begin_text_loc)
+
+        # Name?
+        m = self._try_parse_pat(_py_name_pat)
+
+        if m is not None:
+            return self._ast_expr_from_str(m.group(0), begin_text_loc)
+
+        # Literal string
+        if accept_lit_str:
+            val = self._try_parse_lit_str(True)
+
+            if val is not None:
+                return self._ast_expr_from_str(repr(val), begin_text_loc)
+
+        # Expect `{`
+        msg_accepted_parts = ["a name", "or `{`"]
+
+        if accept_lit_str:
+            msg_accepted_parts.insert(0, "a literal string")
+
+        if accept_const_float:
+            msg_accepted_parts.insert(0, "a constant floating point number")
+
+        if accept_const_int:
+            msg_pos = "" if allow_neg_int else "positive "
+            msg_accepted_parts.insert(0, "a {}constant integer".format(msg_pos))
+
+        if len(msg_accepted_parts) == 2:
+            msg_accepted = " ".join(msg_accepted_parts)
+        else:
+            msg_accepted = ", ".join(msg_accepted_parts)
+
+        self._expect_pat(
+            self._inner_expr_prefix_pat,
+            "Expecting {}".format(msg_accepted),
+        )
+
+        # Expect an expression
+        self._skip_ws_and_comments()
+        expr_text_loc = self._text_loc
+        m = self._expect_pat(self._inner_expr_pat, "Expecting an expression")
+        expr_str = m.group(0)
+
+        # Expect `}`
+        self._skip_ws_and_comments()
+        self._expect_pat(self._inner_expr_suffix_pat, "Expecting `}`")
+
+        return self._ast_expr_from_str(expr_str, expr_text_loc)
+
+    # Patterns for _try_parse_fill_until()
+    _fill_until_prefix_pat = re.compile(r"\+")
+    _fill_until_pad_val_prefix_pat = re.compile(r"~")
+
+    # Tries to parse a filling, returning a filling item on success.
+    def _try_parse_fill_until(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._fill_until_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect expression
+        self._skip_ws_and_comments()
+        expr_str, expr = self._expect_expr(accept_const_int=True)
+
+        # Padding value
+        pad_val = self._parse_pad_val()
+
+        # Return item
+        return _FillUntil(expr_str, expr, pad_val, begin_text_loc)
+
+    # Parses the multiplier expression of a repetition (block or
+    # post-item) and returns the expression string and AST node.
+    def _expect_rep_mul_expr(self):
+        return self._expect_expr(accept_const_int=True)
+
+    # Common block end pattern
+    _block_end_pat = re.compile(r"!end\b")
+
+    # Pattern for _try_parse_rep_block()
+    _rep_block_prefix_pat = re.compile(r"!r(?:epeat)?\b")
+
+    # Tries to parse a repetition block, returning a repetition item on
+    # success.
+    def _try_parse_rep_block(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._rep_block_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect expression
+        self._skip_ws_and_comments()
+        expr_str, expr = self._expect_rep_mul_expr()
+
+        # Parse items
+        self._skip_ws_and_comments_and_syms()
+        items = self._parse_items()
+
+        # Expect end of block
+        self._skip_ws_and_comments_and_syms()
+        self._expect_pat(
+            self._block_end_pat, "Expecting an item or `!end` (end of repetition block)"
+        )
+
+        # Return item
+        return _Rep(items, expr_str, expr, begin_text_loc)
+
+    # Pattern for _try_parse_cond_block()
+    _cond_block_prefix_pat = re.compile(r"!if\b")
+    _cond_block_else_pat = re.compile(r"!else\b")
+
+    # Tries to parse a conditional block, returning a conditional item
+    # on success.
+    def _try_parse_cond_block(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._cond_block_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect expression
+        self._skip_ws_and_comments()
+        expr_str, expr = self._expect_expr()
+
+        # Parse "true" items
+        self._skip_ws_and_comments_and_syms()
+        true_items_text_loc = self._text_loc
+        true_items = self._parse_items()
+        false_items = []  # type: List[_Item]
+        false_items_text_loc = begin_text_loc
+
+        # `!else`?
+        self._skip_ws_and_comments_and_syms()
+
+        if self._try_parse_pat(self._cond_block_else_pat) is not None:
+            # Parse "false" items
+            self._skip_ws_and_comments_and_syms()
+            false_items_text_loc = self._text_loc
+            false_items = self._parse_items()
+
+        # Expect end of block
+        self._expect_pat(
+            self._block_end_pat,
+            "Expecting an item, `!else`, or `!end` (end of conditional block)",
+        )
+
+        # Return item
+        return _Cond(
+            _Group(true_items, true_items_text_loc),
+            _Group(false_items, false_items_text_loc),
+            expr_str,
+            expr,
+            begin_text_loc,
+        )
+
+    # Pattern for _try_parse_trans_block()
+    _trans_block_prefix_pat = re.compile(r"!t(?:ransform)?\b")
+    _trans_block_type_pat = re.compile(
+        r"(?:(?:base|b)64(?:u)?|(?:base|b)(?:16|32)|(?:ascii|a|base|b)85(?:p)?|(?:quopri|qp)(?:t)?|gzip|gz|bzip2|bz2)\b"
+    )
+
+    # Tries to parse a transformation block, returning a transformation
+    # block item on success.
+    def _try_parse_trans_block(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._trans_block_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect type
+        self._skip_ws_and_comments()
+        m = self._expect_pat(
+            self._trans_block_type_pat, "Expecting a known transformation type"
+        )
+
+        # Parse items
+        self._skip_ws_and_comments_and_syms()
+        items = self._parse_items()
+
+        # Expect end of block
+        self._expect_pat(
+            self._block_end_pat,
+            "Expecting an item or `!end` (end of transformation block)",
+        )
+
+        # Choose encoding function
+        enc = m.group(0)
+
+        if enc in ("base64", "b64"):
+            func = base64.standard_b64encode
+            name = "standard Base64"
+        elif enc in ("base64u", "b64u"):
+            func = base64.urlsafe_b64encode
+            name = "URL-safe Base64"
+        elif enc in ("base32", "b32"):
+            func = base64.b32encode
+            name = "Base32"
+        elif enc in ("base16", "b16"):
+            func = base64.b16encode
+            name = "Base16"
+        elif enc in ("ascii85", "a85"):
+            func = base64.a85encode
+            name = "Ascii85"
+        elif enc in ("ascii85p", "a85p"):
+            func = functools.partial(base64.a85encode, pad=True)
+            name = "padded Ascii85"
+        elif enc in ("base85", "b85"):
+            func = base64.b85encode
+            name = "Base85"
+        elif enc in ("base85p", "b85p"):
+            func = functools.partial(base64.b85encode, pad=True)
+            name = "padded Base85"
+        elif enc in ("quopri", "qp"):
+            func = quopri.encodestring
+            name = "MIME quoted-printable"
+        elif enc in ("quoprit", "qpt"):
+            func = functools.partial(quopri.encodestring, quotetabs=True)
+            name = "MIME quoted-printable (with quoted tabs)"
+        elif enc in ("gzip", "gz"):
+            func = gzip.compress
+            name = "gzip"
+        else:
+            assert enc in ("bzip2", "bz2")
+            func = bz2.compress
+            name = "bzip2"
+
+        # Return item
+        return _Trans(
+            items,
+            name,
+            func,
+            begin_text_loc,
+        )
+
+    # Common left parenthesis pattern
+    _left_paren_pat = re.compile(r"\(")
+
+    # Patterns for _try_parse_macro_def() and _try_parse_macro_exp()
+    _macro_params_comma_pat = re.compile(",")
+
+    # Patterns for _try_parse_macro_def()
+    _macro_def_prefix_pat = re.compile(r"!m(?:acro)?\b")
+
+    # Tries to parse a macro definition, adding it to `self._macro_defs`
+    # and returning `True` on success.
+    def _try_parse_macro_def(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._macro_def_prefix_pat) is None:
+            # No match
+            return False
+
+        # Expect a name
+        self._skip_ws_and_comments()
+        name_text_loc = self._text_loc
+        m = self._expect_pat(_py_name_pat, "Expecting a valid macro name")
+
+        # Validate name
+        name = m.group(0)
+
+        if name in self._macro_defs:
+            _raise_error("Duplicate macro named `{}`".format(name), name_text_loc)
+
+        # Expect `(`
+        self._skip_ws_and_comments()
+        self._expect_pat(self._left_paren_pat, "Expecting `(`")
+
+        # Try to parse comma-separated parameter names
+        param_names = []  # type: List[str]
+        expect_comma = False
+
+        while True:
+            self._skip_ws_and_comments()
+
+            # End?
+            if self._try_parse_pat(self._right_paren_pat) is not None:
+                # End
+                break
+
+            # Comma?
+            if expect_comma:
+                self._expect_pat(self._macro_params_comma_pat, "Expecting `,`")
+
+            # Expect parameter name
+            self._skip_ws_and_comments()
+            param_text_loc = self._text_loc
+            m = self._expect_pat(_py_name_pat, "Expecting valid parameter name")
+
+            if m.group(0) in param_names:
+                _raise_error(
+                    "Duplicate macro parameter named `{}`".format(m.group(0)),
+                    param_text_loc,
+                )
+
+            param_names.append(m.group(0))
+            expect_comma = True
+
+        # Expect items
+        self._skip_ws_and_comments_and_syms()
+        old_var_names = self._var_names.copy()
+        old_label_names = self._label_names.copy()
+        self._var_names = set()  # type: Set[str]
+        self._label_names = set()  # type: Set[str]
+        items = self._parse_items()
+        self._var_names = old_var_names
+        self._label_names = old_label_names
+
+        # Expect suffix
+        self._expect_pat(
+            self._block_end_pat, "Expecting an item or `!end` (end of macro block)"
+        )
+
+        # Register macro
+        self._macro_defs[name] = _MacroDef(name, param_names, items, begin_text_loc)
+
+        return True
+
+    # Patterns for _try_parse_macro_exp()
+    _macro_exp_prefix_pat = re.compile(r"m\b")
+    _macro_exp_colon_pat = re.compile(r":")
+
+    # Tries to parse a macro expansion, returning a macro expansion item
+    # on success.
+    def _try_parse_macro_exp(self):
+        begin_text_loc = self._text_loc
+
+        # Match prefix
+        if self._try_parse_pat(self._macro_exp_prefix_pat) is None:
+            # No match
+            return
+
+        # Expect `:`
+        self._skip_ws_and_comments()
+        self._expect_pat(self._macro_exp_colon_pat, "Expecting `:`")
+
+        # Expect a macro name
+        self._skip_ws_and_comments()
+        name_text_loc = self._text_loc
+        m = self._expect_pat(_py_name_pat, "Expecting a valid macro name")
+
+        # Validate name
+        name = m.group(0)
+        macro_def = self._macro_defs.get(name)
+
+        if macro_def is None:
+            _raise_error("Unknown macro name `{}`".format(name), name_text_loc)
+
+        # Expect `(`
+        self._skip_ws_and_comments()
+        self._expect_pat(self._left_paren_pat, "Expecting `(`")
+
+        # Try to parse comma-separated parameter values
+        params_text_loc = self._text_loc
+        params = []  # type: List[_MacroExpParam]
+        expect_comma = False
+
+        while True:
+            self._skip_ws_and_comments()
+
+            # End?
+            if self._try_parse_pat(self._right_paren_pat) is not None:
+                # End
+                break
+
+            # Expect a value
+            if expect_comma:
+                self._expect_pat(self._macro_params_comma_pat, "Expecting `,`")
+
+            self._skip_ws_and_comments()
+            param_text_loc = self._text_loc
+            params.append(
+                _MacroExpParam(
+                    *self._expect_expr(
+                        accept_const_int=True,
+                        allow_neg_int=True,
+                        accept_const_float=True,
+                        accept_lit_str=True,
+                    ),
+                    text_loc=param_text_loc
+                )
+            )
+            expect_comma = True
+
+        # Validate parameter values
+        if len(params) != len(macro_def.param_names):
+            sing_plur = "" if len(params) == 1 else "s"
+            _raise_error(
+                "Macro expansion passes {} parameter{} while the definition expects {}".format(
+                    len(params), sing_plur, len(macro_def.param_names)
+                ),
+                params_text_loc,
+            )
+
+        # Return item
+        return _MacroExp(name, params, begin_text_loc)
+
+    # Tries to parse a base item (anything except a post-item
+    # repetition), returning it on success.
+    def _try_parse_base_item(self):
+        for func in self._base_item_parse_funcs:
+            item = func()
+
+            if item is not None:
+                return item
+
+    # Pattern for _try_parse_rep_post()
+    _rep_post_prefix_pat = re.compile(r"\*")
+
+    # Tries to parse a post-item repetition, returning the expression
+    # string and AST expression node on success.
+    def _try_parse_rep_post(self):
+        # Match prefix
+        if self._try_parse_pat(self._rep_post_prefix_pat) is None:
+            # No match
+            return
+
+        # Return expression string and AST expression
+        self._skip_ws_and_comments()
+        return self._expect_rep_mul_expr()
+
+    # Tries to parse an item, possibly followed by a repetition,
+    # returning `True` on success.
+    #
+    # Appends any parsed item to `items`.
+    def _try_append_item(self, items: List[_Item]):
+        self._skip_ws_and_comments_and_syms()
+
+        # Base item
+        item = self._try_parse_base_item()
+
+        if item is None:
+            return
+
+        # Parse repetition if the base item is repeatable
+        if isinstance(item, _RepableItem):
+            self._skip_ws_and_comments()
+            rep_text_loc = self._text_loc
+            rep_ret = self._try_parse_rep_post()
+
+            if rep_ret is not None:
+                item = _Rep([item], *rep_ret, text_loc=rep_text_loc)
+
+        items.append(item)
+        return True
+
+    # Parses and returns items, skipping whitespaces, insignificant
+    # symbols, and comments when allowed, and stopping at the first
+    # unknown character.
+    #
+    # Accepts and registers macro definitions if `accept_macro_defs`
+    # is `True`.
+    def _parse_items(self, accept_macro_defs: bool = False) -> List[_Item]:
+        items = []  # type: List[_Item]
+
+        while self._isnt_done():
+            # Try to append item
+            if not self._try_append_item(items):
+                if accept_macro_defs and self._try_parse_macro_def():
+                    continue
+
+                # Unknown at this point
+                break
+
+        return items
+
+    # Parses the whole Normand input, setting `self._res` to the main
+    # group item on success.
+    def _parse(self):
+        if len(self._normand.strip()) == 0:
+            # Special case to make sure there's something to consume
+            self._res = _Group([], self._text_loc)
+            return
+
+        # Parse first level items
+        items = self._parse_items(True)
+
+        # Make sure there's nothing left
+        self._skip_ws_and_comments_and_syms()
+
+        if self._isnt_done():
+            self._raise_error(
+                "Unexpected character `{}`".format(self._normand[self._at])
+            )
+
+        # Set main group item
+        self._res = _Group(items, self._text_loc)
+
+
+# The return type of parse().
+class ParseResult:
+    @classmethod
+    def _create(
+        cls,
+        data: bytearray,
+        variables: VariablesT,
+        labels: LabelsT,
+        offset: int,
+        bo: Optional[ByteOrder],
+    ):
+        self = cls.__new__(cls)
+        self._init(data, variables, labels, offset, bo)
+        return self
+
+    def __init__(self, *args, **kwargs):  # type: ignore
+        raise NotImplementedError
+
+    def _init(
+        self,
+        data: bytearray,
+        variables: VariablesT,
+        labels: LabelsT,
+        offset: int,
+        bo: Optional[ByteOrder],
+    ):
+        self._data = data
+        self._vars = variables
+        self._labels = labels
+        self._offset = offset
+        self._bo = bo
+
+    # Generated data.
+    @property
+    def data(self):
+        return self._data
+
+    # Dictionary of updated variable names to their last computed value.
+    @property
+    def variables(self):
+        return self._vars
+
+    # Dictionary of updated main group label names to their computed
+    # value.
+    @property
+    def labels(self):
+        return self._labels
+
+    # Updated offset.
+    @property
+    def offset(self):
+        return self._offset
+
+    # Updated byte order.
+    @property
+    def byte_order(self):
+        return self._bo
+
+
+# Raises a parse error for the item `item`, creating it using the
+# message `msg`.
+def _raise_error_for_item(msg: str, item: _Item) -> NoReturn:
+    _raise_error(msg, item.text_loc)
+
+
+# The `ICITTE` reserved name.
+_icitte_name = "ICITTE"
+
+
+# Base node visitor.
+#
+# Calls the _visit_name() method for each name node which isn't the name
+# of a call.
+class _NodeVisitor(ast.NodeVisitor):
+    def __init__(self):
+        self._parent_is_call = False
+
+    def generic_visit(self, node: ast.AST):
+        if type(node) is ast.Call:
+            self._parent_is_call = True
+        elif type(node) is ast.Name and not self._parent_is_call:
+            self._visit_name(node.id)
+
+        super().generic_visit(node)
+        self._parent_is_call = False
+
+    @abc.abstractmethod
+    def _visit_name(self, name: str):
+        ...
+
+
+# Expression validator: validates that all the names within the
+# expression are allowed.
+class _ExprValidator(_NodeVisitor):
+    def __init__(self, expr_str: str, text_loc: TextLocation, allowed_names: Set[str]):
+        super().__init__()
+        self._expr_str = expr_str
+        self._text_loc = text_loc
+        self._allowed_names = allowed_names
+
+    def _visit_name(self, name: str):
+        # Make sure the name refers to a known and reachable
+        # variable/label name.
+        if name != _icitte_name and name not in self._allowed_names:
+            msg = "Illegal (unknown or unreachable) variable/label name `{}` in expression `{}`".format(
+                name, self._expr_str
+            )
+
+            allowed_names = self._allowed_names.copy()
+            allowed_names.add(_icitte_name)
+
+            if len(allowed_names) > 0:
+                allowed_names_str = ", ".join(
+                    sorted(["`{}`".format(name) for name in allowed_names])
+                )
+                msg += "; the legal names are {{{}}}".format(allowed_names_str)
+
+            _raise_error(
+                msg,
+                self._text_loc,
+            )
+
+
+# Generator state.
+class _GenState:
+    def __init__(
+        self,
+        variables: VariablesT,
+        labels: LabelsT,
+        offset: int,
+        bo: Optional[ByteOrder],
+    ):
+        self.variables = variables.copy()
+        self.labels = labels.copy()
+        self.offset = offset
+        self.bo = bo
+
+    def __repr__(self):
+        return "_GenState({}, {}, {}, {})".format(
+            repr(self.variables), repr(self.labels), repr(self.offset), repr(self.bo)
+        )
+
+
+# Fixed-length number item instance.
+class _FlNumItemInst:
+    def __init__(
+        self,
+        item: _FlNum,
+        offset_in_data: int,
+        state: _GenState,
+        parse_error_msgs: List[ParseErrorMessage],
+    ):
+        self._item = item
+        self._offset_in_data = offset_in_data
+        self._state = state
+        self._parse_error_msgs = parse_error_msgs
+
+    @property
+    def item(self):
+        return self._item
+
+    @property
+    def offset_in_data(self):
+        return self._offset_in_data
+
+    @property
+    def state(self):
+        return self._state
+
+    @property
+    def parse_error_msgs(self):
+        return self._parse_error_msgs
+
+
+# Generator of data and final state from a group item.
+#
+# Generation happens in memory at construction time. After building, use
+# the `data`, `variables`, `labels`, `offset`, and `bo` properties to
+# get the resulting context.
+#
+# The steps of generation are:
+#
+# 1. Handle each item in prefix order.
+#
+#    The handlers append bytes to `self._data` and update some current
+#    state object (`_GenState` instance).
+#
+#    When handling a fixed-length number item, try to evaluate its
+#    expression using the current state. If this fails, then it might be
+#    because the expression refers to a "future" label: save the current
+#    offset in `self._data` (generated data) and a snapshot of the
+#    current state within `self._fl_num_item_insts` (`_FlNumItemInst`
+#    object). _gen_fl_num_item_insts() will deal with this later. A
+#    `_FlNumItemInst` instance also contains a snapshot of the current
+#    parsing error messages (`self._parse_error_msgs`) which need to be
+#    taken into account when handling the instance later.
+#
+#    When handling the items of a group, keep a map of immediate label
+#    names to their offset. Then, after having processed all the items,
+#    update the relevant saved state snapshots in
+#    `self._fl_num_item_insts` with those immediate label values.
+#    _gen_fl_num_item_insts() will deal with this later.
+#
+# 2. Handle all the fixed-length number item instances of which the
+#    expression evaluation failed before.
+#
+#    At this point, `self._fl_num_item_insts` contains everything that's
+#    needed to evaluate the expressions, including the values of
+#    "future" labels from the point of view of some fixed-length number
+#    item instance.
+#
+#    If an evaluation fails at this point, then it's a user error. Add
+#    to the parsing error all the saved parsing error messages of the
+#    instance. Those additional messages add precious context to the
+#    error.
+class _Gen:
+    def __init__(
+        self,
+        group: _Group,
+        macro_defs: _MacroDefsT,
+        variables: VariablesT,
+        labels: LabelsT,
+        offset: int,
+        bo: Optional[ByteOrder],
+    ):
+        self._macro_defs = macro_defs
+        self._fl_num_item_insts = []  # type: List[_FlNumItemInst]
+        self._parse_error_msgs = []  # type: List[ParseErrorMessage]
+        self._in_trans = False
+        self._gen(group, _GenState(variables, labels, offset, bo))
+
+    # Generated bytes.
+    @property
+    def data(self):
+        return self._data
+
+    # Updated variables.
+    @property
+    def variables(self):
+        return self._final_state.variables
+
+    # Updated main group labels.
+    @property
+    def labels(self):
+        return self._final_state.labels
+
+    # Updated offset.
+    @property
+    def offset(self):
+        return self._final_state.offset
+
+    # Updated byte order.
+    @property
+    def bo(self):
+        return self._final_state.bo
+
+    # Evaluates the expression `expr` of which the original string is
+    # `expr_str` at the location `text_loc` considering the current
+    # generation state `state`.
+    #
+    # If `accept_float` is `True`, then the type of the result may be
+    # `float` too.
+    #
+    # If `accept_str` is `True`, then the type of the result may be
+    # `str` too.
+    @staticmethod
+    def _eval_expr(
+        expr_str: str,
+        expr: ast.Expression,
+        text_loc: TextLocation,
+        state: _GenState,
+        accept_float: bool = False,
+        accept_str: bool = False,
+    ):
+        syms = {}  # type: VariablesT
+        syms.update(state.labels)
+
+        # Set the `ICITTE` name to the current offset
+        syms[_icitte_name] = state.offset
+
+        # Add the current variables
+        syms.update(state.variables)
+
+        # Validate the node and its children
+        _ExprValidator(expr_str, text_loc, set(syms.keys())).visit(expr)
+
+        # Compile and evaluate expression node
+        try:
+            val = eval(compile(expr, "", "eval"), None, syms)
+        except Exception as exc:
+            _raise_error(
+                "Failed to evaluate expression `{}`: {}".format(expr_str, exc),
+                text_loc,
+            )
+
+        # Convert `bool` result type to `int` to normalize
+        if type(val) is bool:
+            val = int(val)
+
+        # Validate result type
+        expected_types = {int}  # type: Set[type]
+
+        if accept_float:
+            expected_types.add(float)
+
+        if accept_str:
+            expected_types.add(str)
+
+        if type(val) not in expected_types:
+            expected_types_str = sorted(
+                ["`{}`".format(t.__name__) for t in expected_types]
+            )
+
+            if len(expected_types_str) == 1:
+                msg_expected = expected_types_str[0]
+            elif len(expected_types_str) == 2:
+                msg_expected = " or ".join(expected_types_str)
+            else:
+                expected_types_str[-1] = "or {}".format(expected_types_str[-1])
+                msg_expected = ", ".join(expected_types_str)
+
+            _raise_error(
+                "Invalid expression `{}`: expecting result type {}, not `{}`".format(
+                    expr_str, msg_expected, type(val).__name__
+                ),
+                text_loc,
+            )
+
+        return val
+
+    # Forwards to _eval_expr() with the expression and text location of
+    # `item`.
+    @staticmethod
+    def _eval_item_expr(
+        item: Union[_Cond, _FillUntil, _FlNum, _Leb128Int, _Rep, _Str, _VarAssign],
+        state: _GenState,
+        accept_float: bool = False,
+        accept_str: bool = False,
+    ):
+        return _Gen._eval_expr(
+            item.expr_str, item.expr, item.text_loc, state, accept_float, accept_str
+        )
+
+    # Handles the byte item `item`.
+    def _handle_byte_item(self, item: _Byte, state: _GenState):
+        self._data.append(item.val)
+        state.offset += item.size
+
+    # Handles the literal string item `item`.
+    def _handle_lit_str_item(self, item: _LitStr, state: _GenState):
+        self._data += item.data
+        state.offset += item.size
+
+    # Handles the byte order setting item `item`.
+    def _handle_set_bo_item(self, item: _SetBo, state: _GenState):
+        # Update current byte order
+        state.bo = item.bo
+
+    # Handles the variable assignment item `item`.
+    def _handle_var_assign_item(self, item: _VarAssign, state: _GenState):
+        # Update variable
+        state.variables[item.name] = self._eval_item_expr(
+            item, state, accept_float=True, accept_str=True
+        )
+
+    # Returns the effective byte order to use to encode the fixed-length
+    # number `item` considering the current state `state`.
+    @staticmethod
+    def _fl_num_item_effective_bo(item: _FlNum, state: _GenState):
+        return state.bo if item.bo is None else item.bo
+
+    # Handles the fixed-length number item `item`.
+    def _handle_fl_num_item(self, item: _FlNum, state: _GenState):
+        # Effective byte order
+        bo = self._fl_num_item_effective_bo(item, state)
+
+        # Validate current byte order
+        if bo is None and item.len > 8:
+            _raise_error_for_item(
+                "Current byte order isn't defined at first fixed-length number (`{}`) to encode on more than 8 bits".format(
+                    item.expr_str
+                ),
+                item,
+            )
+
+        # Try an immediate evaluation. If it fails, then keep everything
+        # needed to (try to) generate the bytes of this item later.
+        try:
+            data = self._gen_fl_num_item_inst_data(item, state)
+        except Exception:
+            if self._in_trans:
+                _raise_error_for_item(
+                    "Invalid expression `{}`: failed to evaluate within a transformation block".format(
+                        item.expr_str
+                    ),
+                    item,
+                )
+
+            self._fl_num_item_insts.append(
+                _FlNumItemInst(
+                    item,
+                    len(self._data),
+                    copy.deepcopy(state),
+                    copy.deepcopy(self._parse_error_msgs),
+                )
+            )
+
+            # Reserve space in `self._data` for this instance
+            data = bytes([0] * (item.len // 8))
+
+        # Append bytes
+        self._data += data
+
+        # Update offset
+        state.offset += len(data)
+
+    # Returns the size, in bytes, required to encode the value `val`
+    # with LEB128 (signed version if `is_signed` is `True`).
+    @staticmethod
+    def _leb128_size_for_val(val: int, is_signed: bool):
+        if val < 0:
+            # Equivalent upper bound.
+            #
+            # For example, if `val` is -128, then the full integer for
+            # this number of bits would be [-128, 127].
+            val = -val - 1
+
+        # Number of bits (add one for the sign if needed)
+        bits = val.bit_length() + int(is_signed)
+
+        if bits == 0:
+            bits = 1
+
+        # Seven bits per byte
+        return math.ceil(bits / 7)
+
+    # Handles the LEB128 integer item `item`.
+    def _handle_leb128_int_item(self, item: _Leb128Int, state: _GenState):
+        # Compute value
+        val = self._eval_item_expr(item, state)
+
+        # Size in bytes
+        size = self._leb128_size_for_val(val, type(item) is _SLeb128Int)
+
+        # For each byte
+        for _ in range(size):
+            # Seven LSBs, MSB of the byte set (continue)
+            self._data.append((val & 0x7F) | 0x80)
+            val >>= 7
+
+        # Clear MSB of last byte (stop)
+        self._data[-1] &= ~0x80
+
+        # Update offset
+        state.offset += size
+
+    # Handles the string item `item`.
+    def _handle_str_item(self, item: _Str, state: _GenState):
+        # Compute value
+        val = str(self._eval_item_expr(item, state, accept_float=True, accept_str=True))
+
+        # Encode
+        data = _encode_str(val, item.codec, item.text_loc)
+
+        # Add to data
+        self._data += data
+
+        # Update offset
+        state.offset += len(data)
+
+    # Handles the group item `item`, removing the immediate labels from
+    # `state` at the end if `remove_immediate_labels` is `True`.
+    def _handle_group_item(
+        self, item: _Group, state: _GenState, remove_immediate_labels: bool = True
+    ):
+        first_fl_num_item_inst_index = len(self._fl_num_item_insts)
+        immediate_labels = {}  # type: LabelsT
+
+        # Handle each item
+        for subitem in item.items:
+            if type(subitem) is _Label:
+                # Add to local immediate labels
+                immediate_labels[subitem.name] = state.offset
+
+            self._handle_item(subitem, state)
+
+        # Remove immediate labels from current state if needed
+        if remove_immediate_labels:
+            for name in immediate_labels:
+                del state.labels[name]
+
+        # Add all immediate labels to all state snapshots since
+        # `first_fl_num_item_inst_index`.
+        for inst in self._fl_num_item_insts[first_fl_num_item_inst_index:]:
+            inst.state.labels.update(immediate_labels)
+
+    # Handles the repetition item `item`.
+    def _handle_rep_item(self, item: _Rep, state: _GenState):
+        # Compute the repetition count
+        mul = _Gen._eval_item_expr(item, state)
+
+        # Validate result
+        if mul < 0:
+            _raise_error_for_item(
+                "Invalid expression `{}`: unexpected negative result {:,}".format(
+                    item.expr_str, mul
+                ),
+                item,
+            )
+
+        # Generate group data `mul` times
+        for _ in range(mul):
+            self._handle_group_item(item, state)
+
+    # Handles the conditional item `item`.
+    def _handle_cond_item(self, item: _Cond, state: _GenState):
+        # Compute the conditional value
+        val = _Gen._eval_item_expr(item, state)
+
+        # Generate selected group data
+        if val:
+            self._handle_group_item(item.true_item, state)
+        else:
+            self._handle_group_item(item.false_item, state)
+
+    # Handles the transformation item `item`.
+    def _handle_trans_item(self, item: _Trans, state: _GenState):
+        init_in_trans = self._in_trans
+        self._in_trans = True
+        init_data_len = len(self._data)
+        init_offset = state.offset
+
+        # Generate group data
+        self._handle_group_item(item, state)
+
+        # Remove and keep group data
+        to_trans = self._data[init_data_len:]
+        del self._data[init_data_len:]
+
+        # Encode group data and append to current data
+        try:
+            transformed = item.trans(to_trans)
+        except Exception as exc:
+            _raise_error_for_item(
+                "Cannot apply the {} transformation to this data: {}".format(
+                    item.name, exc
+                ),
+                item,
+            )
+
+        self._data += transformed
+
+        # Update offset and restore
+        state.offset = init_offset + len(transformed)
+        self._in_trans = init_in_trans
+
+    # Evaluates the parameters of the macro expansion item `item`
+    # considering the initial state `init_state` and returns a new state
+    # to handle the items of the macro.
+    def _eval_macro_exp_params(self, item: _MacroExp, init_state: _GenState):
+        # New state
+        exp_state = _GenState({}, {}, init_state.offset, init_state.bo)
+
+        # Evaluate the parameter expressions
+        macro_def = self._macro_defs[item.name]
+
+        for param_name, param in zip(macro_def.param_names, item.params):
+            exp_state.variables[param_name] = _Gen._eval_expr(
+                param.expr_str,
+                param.expr,
+                param.text_loc,
+                init_state,
+                accept_float=True,
+                accept_str=True,
+            )
+
+        return exp_state
+
+    # Handles the macro expansion item `item`.
+    def _handle_macro_exp_item(self, item: _MacroExp, state: _GenState):
+        parse_error_msg_text = "While expanding the macro `{}`:".format(item.name)
+
+        try:
+            # New state
+            exp_state = self._eval_macro_exp_params(item, state)
+
+            # Process the contained group
+            init_data_size = len(self._data)
+            parse_error_msg = (
+                ParseErrorMessage._create(  # pyright: ignore[reportPrivateUsage]
+                    parse_error_msg_text, item.text_loc
+                )
+            )
+            self._parse_error_msgs.append(parse_error_msg)
+            self._handle_group_item(self._macro_defs[item.name], exp_state)
+            self._parse_error_msgs.pop()
+        except ParseError as exc:
+            _augment_error(exc, parse_error_msg_text, item.text_loc)
+
+        # Update state offset and return
+        state.offset += len(self._data) - init_data_size
+
+    # Handles the offset setting item `item`.
+    def _handle_set_offset_item(self, item: _SetOffset, state: _GenState):
+        state.offset = item.val
+
+    # Handles the offset alignment item `item` (adds padding).
+    def _handle_align_offset_item(self, item: _AlignOffset, state: _GenState):
+        init_offset = state.offset
+        align_bytes = item.val // 8
+        state.offset = (state.offset + align_bytes - 1) // align_bytes * align_bytes
+        self._data += bytes([item.pad_val] * (state.offset - init_offset))
+
+    # Handles the filling item `item` (adds padding).
+    def _handle_fill_until_item(self, item: _FillUntil, state: _GenState):
+        # Compute the new offset
+        new_offset = _Gen._eval_item_expr(item, state)
+
+        # Validate the new offset
+        if new_offset < state.offset:
+            _raise_error_for_item(
+                "Invalid expression `{}`: new offset {:,} is less than current offset {:,}".format(
+                    item.expr_str, new_offset, state.offset
+                ),
+                item,
+            )
+
+        # Fill
+        self._data += bytes([item.pad_val] * (new_offset - state.offset))
+
+        # Update offset
+        state.offset = new_offset
+
+    # Handles the label item `item`.
+    def _handle_label_item(self, item: _Label, state: _GenState):
+        state.labels[item.name] = state.offset
+
+    # Handles the item `item`, returning the updated next repetition
+    # instance.
+    def _handle_item(self, item: _Item, state: _GenState):
+        return self._item_handlers[type(item)](item, state)
+
+    # Generates the data for a fixed-length integer item instance having
+    # the value `val` and the effective byte order `bo` and returns it.
+    def _gen_fl_int_item_inst_data(
+        self, val: int, bo: Optional[ByteOrder], item: _FlNum
+    ):
+        # Validate range
+        if val < -(2 ** (item.len - 1)) or val > 2**item.len - 1:
+            _raise_error_for_item(
+                "Value {:,} is outside the {}-bit range when evaluating expression `{}`".format(
+                    val, item.len, item.expr_str
+                ),
+                item,
+            )
+
+        # Encode result on 64 bits (to extend the sign bit whatever the
+        # value of `item.len`).
+        data = struct.pack(
+            "{}{}".format(
+                ">" if bo in (None, ByteOrder.BE) else "<",
+                "Q" if val >= 0 else "q",
+            ),
+            val,
+        )
+
+        # Keep only the requested length
+        len_bytes = item.len // 8
+
+        if bo in (None, ByteOrder.BE):
+            # Big endian: keep last bytes
+            data = data[-len_bytes:]
+        else:
+            # Little endian: keep first bytes
+            assert bo == ByteOrder.LE
+            data = data[:len_bytes]
+
+        # Return data
+        return data
+
+    # Generates the data for a fixed-length floating point number item
+    # instance having the value `val` and the effective byte order `bo`
+    # and returns it.
+    def _gen_fl_float_item_inst_data(
+        self, val: float, bo: Optional[ByteOrder], item: _FlNum
+    ):
+        # Validate length
+        if item.len not in (32, 64):
+            _raise_error_for_item(
+                "Invalid {}-bit length for a fixed-length floating point number (value {:,})".format(
+                    item.len, val
+                ),
+                item,
+            )
+
+        # Encode and return result
+        return struct.pack(
+            "{}{}".format(
+                ">" if bo in (None, ByteOrder.BE) else "<",
+                "f" if item.len == 32 else "d",
+            ),
+            val,
+        )
+
+    # Generates the data for a fixed-length number item instance and
+    # returns it.
+    def _gen_fl_num_item_inst_data(self, item: _FlNum, state: _GenState):
+        # Effective byte order
+        bo = self._fl_num_item_effective_bo(item, state)
+
+        # Compute value
+        val = self._eval_item_expr(item, state, True)
+
+        # Handle depending on type
+        if type(val) is int:
+            return self._gen_fl_int_item_inst_data(val, bo, item)
+        else:
+            assert type(val) is float
+            return self._gen_fl_float_item_inst_data(val, bo, item)
+
+    # Generates the data for all the fixed-length number item instances
+    # and writes it at the correct offset within `self._data`.
+    def _gen_fl_num_item_insts(self):
+        for inst in self._fl_num_item_insts:
+            # Generate bytes
+            try:
+                data = self._gen_fl_num_item_inst_data(inst.item, inst.state)
+            except ParseError as exc:
+                # Add all the saved parse error messages for this
+                # instance.
+                for msg in reversed(inst.parse_error_msgs):
+                    _add_error_msg(exc, msg.text, msg.text_location)
+
+                raise
+
+            # Insert bytes into `self._data`
+            self._data[inst.offset_in_data : inst.offset_in_data + len(data)] = data
+
+    # Generates the data (`self._data`) and final state
+    # (`self._final_state`) from `group` and the initial state `state`.
+    def _gen(self, group: _Group, state: _GenState):
+        # Initial state
+        self._data = bytearray()
+
+        # Item handlers
+        self._item_handlers = {
+            _AlignOffset: self._handle_align_offset_item,
+            _Byte: self._handle_byte_item,
+            _Cond: self._handle_cond_item,
+            _FillUntil: self._handle_fill_until_item,
+            _FlNum: self._handle_fl_num_item,
+            _Group: self._handle_group_item,
+            _Label: self._handle_label_item,
+            _LitStr: self._handle_lit_str_item,
+            _MacroExp: self._handle_macro_exp_item,
+            _Rep: self._handle_rep_item,
+            _SetBo: self._handle_set_bo_item,
+            _SetOffset: self._handle_set_offset_item,
+            _SLeb128Int: self._handle_leb128_int_item,
+            _Str: self._handle_str_item,
+            _Trans: self._handle_trans_item,
+            _ULeb128Int: self._handle_leb128_int_item,
+            _VarAssign: self._handle_var_assign_item,
+        }  # type: Dict[type, Callable[[Any, _GenState], None]]
+
+        # Handle the group item, _not_ removing the immediate labels
+        # because the `labels` property offers them.
+        self._handle_group_item(group, state, False)
+
+        # This is actually the final state
+        self._final_state = state
+
+        # Generate all the fixed-length number bytes now that we know
+        # their full state
+        self._gen_fl_num_item_insts()
+
+
+# Returns a `ParseResult` instance containing the bytes encoded by the
+# input string `normand`.
+#
+# `init_variables` is a dictionary of initial variable names (valid
+# Python names) to integral values. A variable name must not be the
+# reserved name `ICITTE`.
+#
+# `init_labels` is a dictionary of initial label names (valid Python
+# names) to integral values. A label name must not be the reserved name
+# `ICITTE`.
+#
+# `init_offset` is the initial offset.
+#
+# `init_byte_order` is the initial byte order.
+#
+# Raises `ParseError` on any parsing error.
+def parse(
+    normand: str,
+    init_variables: Optional[VariablesT] = None,
+    init_labels: Optional[LabelsT] = None,
+    init_offset: int = 0,
+    init_byte_order: Optional[ByteOrder] = None,
+):
+    if init_variables is None:
+        init_variables = {}
+
+    if init_labels is None:
+        init_labels = {}
+
+    parser = _Parser(normand, init_variables, init_labels)
+    gen = _Gen(
+        parser.res,
+        parser.macro_defs,
+        init_variables,
+        init_labels,
+        init_offset,
+        init_byte_order,
+    )
+    return ParseResult._create(  # pyright: ignore[reportPrivateUsage]
+        gen.data, gen.variables, gen.labels, gen.offset, gen.bo
+    )
+
+
+# Raises a command-line error with the message `msg`.
+def _raise_cli_error(msg: str) -> NoReturn:
+    raise RuntimeError("Command-line error: {}".format(msg))
+
+
+# Returns the `int` or `float` value out of a CLI assignment value.
+def _val_from_assign_val_str(s: str, is_label: bool):
+    s = s.strip()
+
+    # Floating point number?
+    if not is_label:
+        m = _const_float_pat.fullmatch(s)
+
+        if m is not None:
+            return float(m.group(0))
+
+    # Integer?
+    m = _const_int_pat.fullmatch(s)
+
+    if m is not None:
+        return int(_norm_const_int(m.group(0)), 0)
+
+    exp = "an integer" if is_label else "a number"
+    _raise_cli_error("Invalid assignment value `{}`: expecting {}".format(s, exp))
+
+
+# Returns a dictionary of string to numbers from the list of strings
+# `args` containing `NAME=VAL` entries.
+def _dict_from_arg(args: Optional[List[str]], is_label: bool, is_str_only: bool):
+    d = {}  # type: VariablesT
+
+    if args is None:
+        return d
+
+    for arg in args:
+        m = re.match(r"({})\s*=\s*(.*)$".format(_py_name_pat.pattern), arg)
+
+        if m is None:
+            _raise_cli_error("Invalid assignment `{}`".format(arg))
+
+        if is_str_only:
+            val = m.group(2)
+        else:
+            val = _val_from_assign_val_str(m.group(2), is_label)
+
+        d[m.group(1)] = val
+
+    return d
+
+
+# Parses the command-line arguments and returns, in this order:
+#
+# 1. The input file path, or `None` if none.
+# 2. The Normand input text.
+# 3. The initial offset.
+# 4. The initial byte order.
+# 5. The initial variables.
+# 6. The initial labels.
+def _parse_cli_args():
+    import argparse
+
+    # Build parser
+    ap = argparse.ArgumentParser()
+    ap.add_argument(
+        "--offset",
+        metavar="OFFSET",
+        action="store",
+        type=int,
+        default=0,
+        help="initial offset (positive)",
+    )
+    ap.add_argument(
+        "-b",
+        "--byte-order",
+        metavar="BO",
+        choices=["be", "le"],
+        type=str,
+        help="initial byte order (`be` or `le`)",
+    )
+    ap.add_argument(
+        "-v",
+        "--var",
+        metavar="NAME=VAL",
+        action="append",
+        help="add an initial numeric variable (may be repeated)",
+    )
+    ap.add_argument(
+        "-s",
+        "--var-str",
+        metavar="NAME=VAL",
+        action="append",
+        help="add an initial string variable (may be repeated)",
+    )
+    ap.add_argument(
+        "-l",
+        "--label",
+        metavar="NAME=VAL",
+        action="append",
+        help="add an initial label (may be repeated)",
+    )
+    ap.add_argument(
+        "--version", action="version", version="Normand {}".format(__version__)
+    )
+    ap.add_argument(
+        "path",
+        metavar="PATH",
+        action="store",
+        nargs="?",
+        help="input path (none means standard input)",
+    )
+
+    # Parse
+    args = ap.parse_args()
+
+    # Read input
+    if args.path is None:
+        normand = sys.stdin.read()
+    else:
+        with open(args.path) as f:
+            normand = f.read()
+
+    # Variables and labels
+    variables = _dict_from_arg(args.var, False, False)
+    variables.update(_dict_from_arg(args.var_str, False, True))
+    labels = _dict_from_arg(args.label, True, False)
+
+    # Validate offset
+    if args.offset < 0:
+        _raise_cli_error("Invalid negative offset {}")
+
+    # Validate and set byte order
+    bo = None  # type: Optional[ByteOrder]
+
+    if args.byte_order is not None:
+        if args.byte_order == "be":
+            bo = ByteOrder.BE
+        else:
+            assert args.byte_order == "le"
+            bo = ByteOrder.LE
+
+    # Return input and initial state
+    return args.path, normand, args.offset, bo, variables, typing.cast(LabelsT, labels)
+
+
+# CLI entry point without exception handling.
+def _run_cli_with_args(
+    normand: str,
+    offset: int,
+    bo: Optional[ByteOrder],
+    variables: VariablesT,
+    labels: LabelsT,
+):
+    sys.stdout.buffer.write(parse(normand, variables, labels, offset, bo).data)
+
+
+# Prints the exception message `msg` and exits with status 1.
+def _fail(msg: str) -> NoReturn:
+    if not msg.endswith("."):
+        msg += "."
+
+    print(msg.strip(), file=sys.stderr)
+    sys.exit(1)
+
+
+# CLI entry point.
+def _run_cli():
+    try:
+        args = _parse_cli_args()
+    except Exception as exc:
+        _fail(str(exc))
+
+    try:
+        _run_cli_with_args(*args[1:])
+    except ParseError as exc:
+        import os.path
+
+        prefix = "" if args[0] is None else "{}:".format(os.path.abspath(args[0]))
+        fail_msg = ""
+
+        for msg in reversed(exc.messages):
+            fail_msg += "{}{}:{} - {}".format(
+                prefix,
+                msg.text_location.line_no,
+                msg.text_location.col_no,
+                msg.text,
+            )
+
+            if fail_msg[-1] not in ".:;":
+                fail_msg += "."
+
+            fail_msg += "\n"
+
+        _fail(fail_msg.strip())
+    except Exception as exc:
+        _fail(str(exc))
+
+
+if __name__ == "__main__":
+    _run_cli()
This page took 0.051659 seconds and 4 git commands to generate.