Source code for amalgam.amalgams

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from fractions import Fraction
from io import StringIO
from typing import (
    cast,
    Any,
    Callable,
    Generic,
    Iterator,
    List,
    Mapping,
    NamedTuple,
    Sequence,
    Tuple,
    TypeVar,
    TYPE_CHECKING,
)


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


[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, *, lines: Tuple[int, int] = (-1, -1), columns: Tuple[int, int] = (-1, -1), ) -> Located: """ 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 Amalgam(Located, ABC): """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 bind(self, environment: Environment) -> Amalgam: # pragma: no cover """ Protocol for implementing environment binding for :class:`Function`. This base implementation is responsible for allowing `bind` to be called on other :class:`Amalgam` subclasses by performing no operation aside from returning :data:`self`. """ return self
[docs] def call( self, environment: Environment, *arguments: Amalgam ) -> Amalgam: # pragma: no cover """ Protocol for implementing function calls for :class:`Function`. This base implementation is responsible for making the type signature of :attr:`SExpression.func` to properly type check when :meth:`SExpression.evaluate` is called, as well as returning a fatal :class:`.Notification` for non-callable types. """ if isinstance(self, Notification): notification = self value = Atom("call") else: notification = Notification() value = self notification.push(value, environment, "not a callable") return notification
[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: notification = Notification() notification.push(self, environment, "unbound symbol") return notification
def __repr__(self) -> str: # pragma: no cover return self._make_repr(self.value) def __str__(self) -> str: return self.value
[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 def __post_init__(self): self.env = cast("Environment", None) self.in_context = 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.contextual and not self.in_context: notification = Notification() notification.push(self, environment, "invalid context") return notification if self.env is not None: environment = self.env args = [] for argument in arguments: if not self.defer: argument = argument.evaluate(environment) if isinstance(argument, Notification): if argument.fatal: argument.push( Atom(self.name), environment, "inherited", ) return argument args.append(argument) 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`. """ result = self.func.evaluate(environment).call(environment, *self.args) if isinstance(result, Notification): if result.fatal: result.push(self, environment, "inherited") return result
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`. """ vals = [] for val in self.vals: val = val.evaluate(environment) if isinstance(val, Notification): if val.fatal: val.push(self, environment, "inherited") return val vals.append(val) return Vector(*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 __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}"
P = TypeVar("P", bound=object)
[docs]@dataclass(repr=False) class Internal(Amalgam, Generic[P]): """ An :class:`Amalgam` that holds Python :class:`object` s. Parameterized as a :class:`Generic` by: :data:`P = TypeVar("P", bound=object)` Attributes: value (:data:`P`): The Python :class:`object` being wrapped. """ value: P
[docs] def evaluate(self, _environment: Environment) -> Internal: """Evaluates to the same :class:`.Internal` reference.""" return self
def __repr__(self) -> str: # pragma: no cover return self._make_repr(repr(self.value)) def __str__(self) -> str: # pragma: no cover return f"~{self.value!s}~"
[docs]class Trace(NamedTuple): """Encapsulates information for tracking notifications.""" amalgam: Amalgam environment: Environment message: str
[docs]@dataclass(init=False, repr=False) class Notification(Amalgam): """ An :class:`Amalgam` that encapsulates and tracks notifications. Attributes: fatal (:class:`bool`): Specifies whether the notification should unconditionally propagate and halt evaluation. payload (:class:`Amalgam`): An optional payload to be carried by a notification. trace (:class:`List[Trace]`): A stack of :class:`.Trace` objects that tell how the notification propagated. """ def __init__( self, *, fatal: bool = True, payload: Amalgam = Atom("NIL"), ) -> None: self.fatal = fatal self.payload = payload self.trace: List[Trace] = []
[docs] def evaluate(self, _environment: Environment) -> Notification: """Evaluates to the same :class:`.Notification` reference.""" return self
[docs] def push( self, amalgam: Amalgam, environment: Environment, message: str, ) -> None: """Pushes a :class:`.Trace` into :attr:`Notification.trace`.""" self.trace.append(Trace(amalgam, environment, message))
[docs] def pop(self) -> Trace: """Pops a :class:`.Trace` from :attr:`Notification.trace`.""" return self.trace.pop()
[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. """ trace = [] for a, e, m in self.trace: if isinstance(a, Atom): continue if a.line_span == (-1, -1) or a.column_span == (-1, -1): continue trace.append((a, e, m)) if isinstance(a, (SExpression, Vector)): break lines = text.splitlines() if len(trace) > 1: (atom_a, atom_e, atom_m), *_, (expr_a, _, _) = trace 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), *_ = trace 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()
def __repr__(self) -> str: # pragma: no cover return self._make_repr( f"fatal={self.fatal}, payload={self.payload}, trace={self.trace}" ) def __iter__(self) -> Iterator[Trace]: return reversed(self.trace)
def create_fn( fname: str, fargs: Sequence[str], fbody: Amalgam, defer: bool = False, contextual: bool = False, ) -> Function: """Helper function for creating `Function` objects. Given the name of the function: `fname`, a sequence of argument names: `fargs`, and the `Amalgam` to be evaluated: `fbody`, creates a new `closure_fn` to be wrapped by a `Function`. """ def closure_fn(environment: Environment, *arguments: Amalgam) -> Amalgam: """Callable responsible for evaluating `fbody`.""" # Create a child environment and bind args to their names. # TODO: Raise an error when missing arguments instead. cl_env = environment.env_push( dict(zip(fargs, arguments)), f"{fname}-closure", ) # Call the `evaluate` method on the function body with # `cl_env` and then call `bind` on the result with the # same environment. return fbody.evaluate(cl_env).bind(cl_env) return Function(fname, closure_fn, defer, contextual)