Source code for amalgam.amalgams

from __future__ import annotations

from abc import ABCMeta, abstractmethod
from dataclasses import dataclass, field
from functools import wraps
from fractions import Fraction
from io import StringIO
from itertools import chain
from typing import (
    cast,
    Any,
    Callable,
    Generic,
    Iterator,
    List,
    Mapping,
    Sequence,
    Tuple,
    TypeVar,
    TYPE_CHECKING,
)


if TYPE_CHECKING:  # pragma: no cover
    from amalgam.environment import Environment


L = TypeVar("L", bound="Located")


[docs]@dataclass class Located: """ The base dataclass for encapsulating location data of nodes. Provides an API similar to Lark's :class:`Token` class for convenience. Attributes: line_span (:class:`Tuple[int, int]`): Lines spanned by a node column_span (:class:`Tuple[int, int]`): Columns spanned by a node """ line_span: Tuple[int, int] = field( init=False, compare=False, repr=False, default=(-1, -1), ) column_span: Tuple[int, int] = field( init=False, compare=False, repr=False, default=(-1, -1), ) @property def line(self) -> int: """The starting line number of a node.""" return self.line_span[0] @property def end_line(self) -> int: """The ending line number of a node.""" return self.line_span[1] @property def column(self) -> int: """The starting column number of a node.""" return self.column_span[0] @property def end_column(self) -> int: """The ending column number of a node.""" return self.column_span[1]
[docs] def located_on( self: L, *, lines: Tuple[int, int] = (-1, -1), columns: Tuple[int, int] = (-1, -1), ) -> L: """ Helper method for setting :attr:`Located.line_span` and :attr:`Located.column_span`. """ self.line_span = lines self.column_span = columns return self
[docs]class Failure(Exception): """ Represents failures during evaluation. Attributes: amalgam (:class:`Amalgam`): The :class:`Amalgam` where evaluation failed. environment (:class:`Environment`): The execution environment used to evaluate :data:`amalgam`. message (:class:`str`): An error message attached to the failure. """ def __init__( self, amalgam: Amalgam, environment: Environment, message: str ) -> None: self.amalgam = amalgam self.environment = environment self.message = message
[docs]class FailureStack(Exception): """ Represents a collection of :class:`Failure` instances. Attributes: failures (:class:`List[Failure]`): A stack of :class:`Failure` instances. """ def __init__(self, failures: List[Failure]) -> None: self.failures = failures
[docs] def push(self, failure: Failure) -> None: """Pushes a :class:`Failure` into the :attr:`failures` stack.""" self.failures.append(failure)
@property def unpacked_failures(self) -> Iterator[Tuple[Amalgam, Environment, str]]: """Helper property for unpacking :class:`Failure` s.""" for failure in self.failures: yield (failure.amalgam, failure.environment, failure.message)
[docs] def make_report( self, text: str, source: str = "<unknown>" ) -> str: # pragma: no cover """ Generates a report to be printed to :data:`sys.stderr`. Accepts :data:`text` and :data:`source` for prettified output. """ lines = text.splitlines() if len(self.failures) > 1: (atom_a, atom_e, atom_m), *_, (expr_a, _, _) = self.unpacked_failures snippets = lines[expr_a.line - 1:expr_a.end_line] _code_block = [] for line_no, snippet in enumerate(snippets, start=expr_a.line): padding = 6 - len(str(line_no)) _code_block.append(f"{line_no:>{padding}} | {snippet}") code_block = "\n".join(_code_block) else: (atom_a, atom_e, atom_m), *_ = self.unpacked_failures snippet = lines[atom_a.line - 1] padding = 6 - len(str(atom_a.line)) code_block = f"{atom_a.line:>{padding}} | {snippet}" line_span = f"{atom_a.line}~{atom_a.end_line}" column_span = f"{atom_a.column}~{atom_a.end_column}" message = f"{atom_a!s} ~ {atom_m}" environment = atom_e.name report = StringIO() report.write( f"In file \"{source}\" " f"near lines {line_span}, columns {column_span}\n" f" |\n" f"{code_block}\n" f" |\n" f" Message: {message}, Environment: {environment}\n" ) report.seek(0) return report.read()
class AmalgamMeta(ABCMeta): """ Metaclass used to build :class:`Amalgam` subclasses. Allows for customized pre and post method execution behaviour, such as logging calls or tracking exceptions, effectively reducing boilerplate code. """ def __new__(cls, name, bases, namespace): namespace["__evaluate"] = namespace["evaluate"] @wraps(namespace["__evaluate"]) def evaluate(self: Amalgam, environment: Environment) -> Amalgam: try: return namespace["__evaluate"](self, environment) except Failure as f: raise FailureStack([f]) except FailureStack as s: s.push(Failure(self, environment, "inherited")) raise except Exception: raise namespace["evaluate"] = evaluate return super().__new__(cls, name, bases, namespace)
[docs]class Amalgam(Located, metaclass=AmalgamMeta): """The abstract base class for language constructs."""
[docs] @abstractmethod def evaluate(self, environment: Environment) -> Any: """ Protocol for evaluating or unwrapping :class:`Amalgam` objects. """
[docs] def _make_repr(self, value: Any) -> str: # pragma: no cover """Helper method for creating a :meth:`__repr__`.""" return f"<{self.__class__.__name__} '{value!s}' @ {hex(id(self))}>"
[docs]@dataclass(repr=False) class Atom(Amalgam): """ An :class:`.Amalgam` that represents different atoms. Attributes: value (:class:`str`): The name of the atom. """ value: str
[docs] def evaluate(self, _environment: Environment) -> Atom: """Evaluates to the same :class:`.Atom` reference.""" return self
def __repr__(self) -> str: # pragma: no cover return self._make_repr(self.value) def __str__(self) -> str: return f":{self.value}"
N = TypeVar("N", int, float, Fraction)
[docs]@dataclass(repr=False, order=True) class Numeric(Amalgam, Generic[N]): """ An :class:`.Amalgam` that wraps around numeric types. Parameterized as a :class:`Generic` by: :data:`N = TypeVar("N", int, float, Fraction)` Attributes: value (:data:`N`): The numeric value being wrapped. """ value: N
[docs] def evaluate(self, _environment: Environment) -> Numeric: """Evaluates to the same :class:`.Numeric` reference.""" return self
def __repr__(self) -> str: # pragma: no cover return self._make_repr(self.value) def __str__(self) -> str: return str(self.value)
@dataclass(repr=False, order=True) class String(Amalgam): """ An :class:`.Amalgam` that wraps around strings. Attributes: value (:class:`str`): The string being wrapped. """ value: str def evaluate(self, _environment: Environment) -> String: """Evaluates to the same :class:`.String` reference.""" return self def __repr__(self) -> str: # pragma: no cover return self._make_repr(f"\"{self.value}\"") def __str__(self) -> str: return f"\"{self.value}\""
[docs]@dataclass(repr=False) class Symbol(Amalgam): """ An :class:`.Amalgam` that wraps around symbols. Attributes: value (:class:`str`): The name of the symbol. """ value: str
[docs] def evaluate(self, environment: Environment) -> Amalgam: """ Searches the provided `environment` fully with :attr:`Symbol.value`. Returns the :class:`.Amalgam` object bound to the :attr:`Symbol.value` in the environment. Returns a fatal :class:`.Notification` if a binding is not found. """ try: with environment.search_at(depth=-1): return environment[self.value] except KeyError: raise Failure(self, environment, "unbound symbol")
def __repr__(self) -> str: # pragma: no cover return self._make_repr(self.value) def __str__(self) -> str: return self.value
class InvalidContextError(Exception): """ Raised when calling a :class:`Function` in invalid contexts. Attributes: environment (:class:`Environment`): The environment used in calling the :class:`Function`. """ def __init__(self, environment: Environment) -> None: self.environment = environment
[docs]@dataclass(repr=False) class Function(Amalgam): """ An :class:`.Amalgam` that wraps around functions. Attributes: name (:class:`str`): The name of the function. fn (:class:`Callable[..., Amalgam]`): The function being wrapped. Must have the signature: `(env, amalgams...) -> amalgam`. defer (:class:`bool`): If set to :obj:`False`, arguments are evaluated before being passed to :attr:`Function.fn`. contextual (:class:`bool`): If set to :obj:`True`, disallows function calls when :attr:`.Function.in_context` is set to :obj:`False`. env (:class:`.environment.Environment`): The :class:`.environment.Environment` instance bound to the function. Overrides the `environment` parameter passed to the :meth:`.Function.call` method. in_context (:class:`bool`): Predicate that disallows functions to be called outside of specific contexts. Makes :meth:`.Function.call` return a fatal :class:`.Notification` when set to :obj:`False` and :attr:`.Function.contextual` is set to :obj:`True`. """ name: str fn: Callable[..., Amalgam] defer: bool = False contextual: bool = False env: Environment = field( init=False, compare=False, default=cast("Environment", None) ) in_context: bool = field(init=False, compare=False, default=False)
[docs] def evaluate(self, _environment: Environment) -> Function: """Evaluates to the same :class:`.Function` reference.""" return self
[docs] def bind(self, environment: Environment) -> Function: """ Sets the :attr:`.Function.env` attribute and returns the same :class:`.Function` reference. """ self.env = environment return self
[docs] def call(self, environment: Environment, *arguments: Amalgam) -> Amalgam: """ Performs the call to the :attr:`.Function.fn` attribute. Performs pre-processing depending on the values of :attr:`.Function.defer`, :attr:`.Function.contextual`, and :attr:`.Function.in_context`, """ if self.env is not None: environment = self.env if self.contextual and not self.in_context: raise InvalidContextError(environment) args = [ argument if self.defer else argument.evaluate(environment) for argument in arguments ] return self.fn(environment, *args)
[docs] def with_name(self, name: str) -> Function: """ Sets the :attr:`.Function.name` attribute and returns the same :class:`.Function` reference. """ self.name = name return self
def __repr__(self) -> str: # pragma: no cover return self._make_repr(self.name) def __str__(self) -> str: # pragma: no cover return self.name
[docs]@dataclass(init=False, repr=False) class SExpression(Amalgam): """ An :class:`.Amalgam` that wraps around S-Expressions. Attributes: vals (:class:`Tuple[Amalgam, ...]`): Entities contained by the S-Expression. """ vals: Tuple[Amalgam, ...] def __init__(self, *vals: Amalgam) -> None: self.vals = vals @property def func(self) -> Amalgam: """The head of the :attr:`SExpression.vals`.""" return self.vals[0] @property def args(self) -> Tuple[Amalgam, ...]: """The rest of the :attr:`SExpression.vals`.""" return self.vals[1:]
[docs] def evaluate(self, environment: Environment) -> Amalgam: """ Evaluates :attr:`func` using `environment` before invoking the :meth:`call` method with `environment` and :attr:`SExpression.args`. """ head = self.func.evaluate(environment) if isinstance(head, Function): try: return head.call(environment, *self.args) except InvalidContextError as e: # Instead of raising Failure with the Function instance, # we try to reconstruct a sensible Failure using func, # assuming that it's an AST node that we can use for # error reporting. raise FailureStack( [Failure(self.func, e.environment, "invalid context")], ) else: raise FailureStack([Failure(head, environment, "not a callable")])
def __iter__(self) -> Iterator[Amalgam]: return iter(self.vals) def __len__(self) -> int: return len(self.vals) def __repr__(self) -> str: # pragma: no cover return self._make_repr(f"{self.func!r} {' '.join(map(repr, self.args))}") def __str__(self) -> str: return f"({' '.join(map(str, self.vals))})"
T = TypeVar("T", bound=Amalgam)
[docs]@dataclass(init=False, repr=False) class Vector(Amalgam, Generic[T]): """ An :class:`.Amalgam` that wraps around a homogenous vector. Parameterized as a :class:`Generic` by: :data:`T = TypeVar("T", bound=Amalgam)` Attributes: vals (:class:`Tuple[T, ...]`): Entities contained by the vector mapping (:class:`Mapping[str, Amalgam]`): Mapping representing vectors with :class:`.Atom` s for odd indices and :class:`.Amalgam` s for even indices. """ vals: Tuple[T, ...] def __init__(self, *vals: T) -> None: self.vals = vals self.mapping = self._as_mapping()
[docs] def evaluate(self, environment: Environment) -> Amalgam: """ Creates a new :class:`.Vector` by evaluating every value in :attr:`Vector.vals`. """ return Vector(*(val.evaluate(environment) for val in self.vals))
[docs] def _as_mapping(self) -> Mapping[str, Amalgam]: """ Attemps to create a :class:`Mapping[str, Amalgam]` from :attr:`Vector.vals`. Odd indices must be :class:`.Atom` s and even indices must be :class:`.Amalgam` s. Returns an empty mapping if this form is not met. """ if len(self.vals) % 2 != 0 or len(self.vals) == 0: return {} mapping = {} atoms = self.vals[::2] amalgams = self.vals[1::2] for atom, amalgam in zip(atoms, amalgams): if not isinstance(atom, Atom): return {} mapping[atom.value] = amalgam return mapping
def __iter__(self) -> Iterator[T]: return iter(self.vals) def __len__(self) -> int: return len(self.vals) def __repr__(self) -> str: # pragma: no cover return self._make_repr(" ".join(map(repr, self.vals))) def __str__(self) -> str: return f"[{' '.join(map(str, self.vals))}]"
[docs]@dataclass(repr=False) class Quoted(Amalgam, Generic[T]): """ An :class:`Amalgam` that defers evaluation of other :class:`Amalgam` s. Parameterized as a :class:`Generic` by: :data:`T = TypeVar("T", bound=Amalgam)` Attributes: value (:data:`T`): The :class:`.Amalgam` being deferred. """ value: T
[docs] def evaluate(self, _environment: Environment) -> Quoted: """Evaluates to the same :class:`.Quoted` reference.""" return self
def __repr__(self) -> str: # pragma: no cover return self._make_repr(repr(self.value)) def __str__(self) -> str: return f"'{self.value!s}"
def create_fn( fname: str, fargs: Sequence[str], fbody: Amalgam, defer: bool = False, contextual: bool = False, ) -> Function: """ Helper function for creating :class:`Function` objects. Given the name of the function: :data:`fname`, a sequence of argument names: :data:`fargs`, and the :class:`Amalgam` to be evaluated: :data:`fbody`, creates a new :data:`closure_fn` to be wrapped by a :class:`Function`. :data:`fargs` can include :data:`&rest` to signify variadic arguments, and can be used in the following forms. Variadic for all arguments (λ [&rest]-> [&rest]) 1 2 3 == [[1 2 3]] Non-variadic for first :data:`n` arguments (λ [x &rest] -> [x &rest]) 1 2 3 == [1 [2 3]] Non-variadic for last :data:`n` arguments (λ [&rest x] -> [&rest x]) 1 2 3 == [[1 2] 3] Non-variadic for first :data:`n` and last:data:`m` arguments (λ [x &rest y] -> [x &rest y]) 1 2 3 == [1 [2] 3] """ def closure_fn(environment: Environment, *arguments: Amalgam) -> Amalgam: """Callable responsible for evaluating `fbody`.""" try: l_count = fargs.index("&rest") r_count = len(fargs) - l_count - 1 l_names = zip(fargs[:l_count], arguments[:l_count]) if r_count == 0: bindings = dict(l_names) bindings["&rest"] = Vector(*arguments[l_count:]) else: r_names = zip(fargs[-r_count:], arguments[-r_count:]) m_name = ("&rest", Vector(*arguments[l_count:-r_count])) bindings = dict(chain(l_names, (m_name,), r_names)) except ValueError: bindings = dict(zip(fargs, arguments)) cl_env = environment.env_push(bindings, f"{fname}-closure") result = fbody.evaluate(cl_env) if isinstance(result, Function): return result.bind(cl_env) else: return result return Function(fname, closure_fn, defer, contextual)