Compare commits

...

18 Commits

Author SHA1 Message Date
f67d1e2730
Updated README 2022-10-28 20:45:39 -07:00
03e742821d
Comments 2022-10-28 20:45:26 -07:00
602bf52983
Better number formatting 2022-10-28 20:43:00 -07:00
4847f0a9a4
Minor improvements 2022-10-28 19:48:12 -07:00
fbf2d6f36d
Added a new command 2022-10-28 19:47:50 -07:00
6e17963d91
Changed icon 2022-10-28 19:42:32 -07:00
393e52327e
Added runtime measurement 2022-10-28 19:05:38 -07:00
6e46f485c1
Added live reduction counter 2022-10-28 18:58:25 -07:00
e41de764e8
Fixed a bug 2022-10-28 17:40:48 -07:00
8871f1430d
Fixed a bad macro 2022-10-28 17:40:11 -07:00
affcbc33ee
Cleanup 2022-10-28 16:01:58 -07:00
a1d8714f2f
Re-integrated a few features 2022-10-28 14:19:29 -07:00
d74922a363
Reorganized nodes 2022-10-28 08:33:52 -07:00
8d1abe2712
Fixed a few final bugs 2022-10-27 21:06:07 -07:00
0dae1afb61
Added tree iterator, cloning 2022-10-27 20:48:03 -07:00
a991c3bb91
Rewrite continues 2022-10-25 13:26:55 -07:00
455e447999
Added variable binding 2022-10-23 11:24:27 -07:00
c5df3fcbed
Started conversion to Tree reduction 2022-10-23 08:53:26 -07:00
13 changed files with 915 additions and 823 deletions

View File

@ -6,6 +6,7 @@
"onefile",
"Packrat",
"pyparsing",
"rlimit",
"runstatus",
"srange",
"subvar"

106
README.md
View File

@ -1,27 +1,95 @@
# Lamb: A Lambda Calculus Engine
![Lamb screenshot](./misc/screenshot.png)
## Installation
### Method 1: PyPi (not yet)
1. Put this on PyPi
2. Write these instructions
### Method 2: Git
1. Clone this repository.
2. Make and enter a [virtual environment](https://docs.python.org/3/library/venv.html).
3. ``cd`` into this directory
4. Run ``pip install .``
5. Run ``python .``
-------------------------------------------------
## Usage
Type lambda expressions into the prompt, and Lamb will evaluate them. \
Use your `\` (backslash) key to type a `λ`. \
To define macros, use `=`. For example,
```
~~> T = λab.a
~~> F = λab.a
~~> NOT = λa.a F T
```
Note that there are spaces in `λa.a F T`. With no spaces, `aFT` will be parsed as one variable. \
Lambda functions can only take single-letter, lowercase arguments. `λA.A` is not valid syntax. \
Unbound variables (upper and lower case) that aren't macros will become free variables.
Be careful, macros are case-sensitive. If you define a macro `MAC` and accidentally write `mac` in the prompt, `mac` will become a free variable.
Numbers will automatically be converted to Church numerals. For example, the following line will reduce to `T`.
```
~~> 3 NOT F
```
If an expression takes too long to evaluate, you may interrupt reduction with `Ctrl-C`.
-------------------------------------------------
## Commands
Lamb comes with a few commands. Prefix them with a `:`
`:help` Prints a help message
`:clear` Clear the screen
`:rlimit [int | None]` Set maximum reduction limit. `:rlimit none` sets no limit.
`:macros` List macros in the current environment.
`:mdel [macro]` Delete a macro
`:save [filename]`\
`:load [filename]` Save or load the current environment to a file. The lines in a file look exactly the same as regular entries in the prompt, but must only contain macro definitions.
-------------------------------------------------
## Internals
Lamb treats each λ expression as a binary tree. Variable binding and reduction are all simple operations on that tree. All the magic happens inside [`nodes.py`](./lamb/nodes.py).
**Highlights:**
- `TreeWalker` is the iterator we (usually) use to traverse our tree. It walks the "perimeter" of the tree, visiting some nodes multiple times.
- `Node` is the base class for all nodes. Any node has `.left` and `.right` elements, which may be `None` (empty). `Node`s also reference their parent and their direction relative to their parent, to make tree traversal easy.
- Before any reduction is done, variables are bound via `bind_variables`. This prevents accidental conflicts common in many lambda parsers.
-------------------------------------------------
## Todo (pre-release):
- $\alpha$-equivalence check
- Prettyprint functions (combine args, rename bound variables)
- Write a nice README
- Handle or avoid recursion errors
- Fix colors
- Print macro content if only a macro is typed
- Make command output accessible in prompt
- Prettyprint functions and rename bound variables
- Prettier colors
- Prevent macro-chaining recursion
- step-by-step reduction
- Show a warning when a free variable is created
- PyPi package
## Todo:
- live syntax check
- Command and macro autocomplete
- step-by-step reduction
- Maybe a better icon?
- Syntax highlighting: parenthesis, bound variables, macros, etc
- Pin header to top of screen
- PyPi package
- Command-line options (load a file, run a set of commands)
- $\alpha$-equivalence check
- Unchurch macro: make church numerals human-readable
- Full-reduce option (expand all macros)
- Print macro content if only a macro is typed
- Smart alignment in all printouts
- Full-reduce option
- Set reduction limit command
- Syntax highlighting: parenthesis, bound variables, macros, etc
## Mention in Docs
- lambda functions only work with single-letter arguments
- church numerals
- how to install

View File

@ -0,0 +1,7 @@
from . import utils
from . import node
from . import parser
from . import commands
from .runner import Runner
from .runner import StopReason

View File

@ -5,12 +5,9 @@ from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.formatted_text import to_plain_text
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.lexers import Lexer
from pyparsing import exceptions as ppx
import lamb.runner as runner
import lamb.tokens as tokens
import lamb.utils as utils
import lamb
# Simple lexer for highlighting.
@ -22,7 +19,7 @@ class LambdaLexer(Lexer):
return inner
utils.show_greeting()
lamb.utils.show_greeting()
# Replace "\" with pretty "λ"s
@ -32,25 +29,25 @@ def _(event):
event.current_buffer.insert_text("λ")
r = runner.Runner(
r = lamb.Runner(
prompt_session = PromptSession(
style = utils.style,
style = lamb.utils.style,
lexer = LambdaLexer(),
key_bindings = bindings
),
prompt_message = FormattedText([
("class:prompt", "~~> ")
]),
])
)
r.run_lines([
"T = λab.a",
"F = λab.b",
"NOT = λa.(a F T)",
"AND = λab.(a F b)",
"AND = λab.(a b F)",
"OR = λab.(a T b)",
"XOR = λab.(a (NOT a b) b)",
"XOR = λab.(a (NOT b) b)",
"M = λx.(x x)",
"W = M M",
"Y = λf.( (λx.(f (x x))) (λx.(f (x x))) )",
@ -80,7 +77,6 @@ while True:
if i.strip() == "":
continue
# Try to run an input line.
# Catch parse errors and point them out.
try:
@ -91,12 +87,12 @@ while True:
("class:err", " "*(e.loc + l) + "^\n"),
("class:err", f"Syntax error at char {e.loc}."),
("class:text", "\n")
]), style = utils.style)
]), style = lamb.utils.style)
continue
except tokens.ReductionError as e:
except lamb.node.ReductionError as e:
printf(FormattedText([
("class:err", f"{e.msg}\n")
]), style = utils.style)
]), style = lamb.utils.style)
continue
printf("")

View File

@ -4,40 +4,39 @@ from prompt_toolkit import print_formatted_text as printf
from prompt_toolkit.shortcuts import clear as clear_screen
import os.path
from pyparsing import exceptions as ppx
import lamb.tokens
import lamb.utils
import lamb
commands = {}
help_texts = {}
def lamb_command(*, help_text: str):
def lamb_command(
*,
command_name: str | None = None,
help_text: str
):
"""
A decorator that allows us to easily make commands
"""
def inner(func):
commands[func.__name__] = func
help_texts[func.__name__] = help_text
name = func.__name__ if command_name is None else command_name
commands[name] = func
help_texts[name] = help_text
return inner
def run(command, runner) -> None:
if command.name not in commands:
printf(
FormattedText([
("class:warn", f"Unknown command \"{command.name}\"")
]),
style = lamb.utils.style
)
else:
commands[command.name](command, runner)
@lamb_command(help_text = "Save macros to a file")
def save(command, runner) -> None:
@lamb_command(
command_name = "save",
help_text = "Save macros to a file"
)
def cmd_save(command, runner) -> None:
if len(command.args) != 1:
printf(
HTML(
"<err>Command <cmd_code>:save</cmd_code> takes exactly one argument.</err>"
f"<err>Command <cmd_code>:{command.name}</cmd_code> takes exactly one argument.</err>"
),
style = lamb.utils.style
)
@ -74,12 +73,15 @@ def save(command, runner) -> None:
)
@lamb_command(help_text = "Load macros from a file")
def load(command, runner):
@lamb_command(
command_name = "load",
help_text = "Load macros from a file"
)
def cmd_load(command, runner):
if len(command.args) != 1:
printf(
HTML(
"<err>Command <cmd_code>:load</cmd_code> takes exactly one argument.</err>"
f"<err>Command <cmd_code>:{command.name}</cmd_code> takes exactly one argument.</err>"
),
style = lamb.utils.style
)
@ -114,7 +116,7 @@ def load(command, runner):
)
return
if not isinstance(x, lamb.tokens.macro_expression):
if not isinstance(x, lamb.runner.MacroDef):
printf(
FormattedText([
("class:warn", f"Skipping line {i+1:02}: "),
@ -136,13 +138,14 @@ def load(command, runner):
)
@lamb_command(help_text = "Delete a macro")
@lamb_command(
help_text = "Delete a macro"
)
def mdel(command, runner) -> None:
if len(command.args) != 1:
printf(
HTML(
"<err>Command <cmd_code>:mdel</cmd_code> takes exactly one argument.</err>"
f"<err>Command <cmd_code>:{command.name}</cmd_code> takes exactly one argument.</err>"
),
style = lamb.utils.style
)
@ -161,8 +164,9 @@ def mdel(command, runner) -> None:
del runner.macro_table[target]
@lamb_command(help_text = "Show macros")
@lamb_command(
help_text = "Show macros"
)
def macros(command, runner) -> None:
printf(FormattedText([
("class:cmd_h", "\nDefined Macros:\n"),
@ -174,17 +178,92 @@ def macros(command, runner) -> None:
style = lamb.utils.style
)
@lamb_command(help_text = "Clear the screen")
@lamb_command(
help_text = "Clear the screen"
)
def clear(command, runner) -> None:
clear_screen()
lamb.utils.show_greeting()
@lamb_command(
help_text = "Get or set reduction limit"
)
def rlimit(command, runner) -> None:
if len(command.args) == 0:
if runner.reduction_limit is None:
printf(
HTML(
"<ok>No reduction limit is set</ok>"
),
style = lamb.utils.style
)
else:
printf(
HTML(
f"<ok>Reduction limit is {runner.reduction_limit:,}</ok>"
),
style = lamb.utils.style
)
return
@lamb_command(help_text = "Print this help")
elif len(command.args) != 1:
printf(
HTML(
f"<err>Command <cmd_code>:{command.name}</cmd_code> takes exactly one argument.</err>"
),
style = lamb.utils.style
)
return
t = command.args[0]
if t.lower() == "none":
runner.reduction_limit = None
printf(
HTML(
f"<ok>Removed reduction limit</ok>"
),
style = lamb.utils.style
)
return
try:
t = int(t)
except ValueError:
printf(
HTML(
"<err>Reduction limit must be a positive integer or \"none\".</err>"
),
style = lamb.utils.style
)
return
if 50 > t:
printf(
HTML(
"<err>Reduction limit must be at least 50.</err>"
),
style = lamb.utils.style
)
return
runner.reduction_limit = t
printf(
HTML(
f"<ok>Set reduction limit to {t:,}</ok>"
),
style = lamb.utils.style
)
@lamb_command(
help_text = "Print this help"
)
def help(command, runner) -> None:
printf(
HTML(
"\n<cmd_text>" +
"<cmd_h>Usage:</cmd_h>" +
"\n" +
"\tWrite lambda expressions using your <cmd_key>\\</cmd_key> key." +
@ -199,6 +278,8 @@ def help(command, runner) -> None:
f"\t<cmd_code>{name}</cmd_code> \t {text}"
for name, text in help_texts.items()
]) +
"\n\n"
"<muted>Detailed documentation can be found on this project's git page.</muted>" +
"</cmd_text>"
),
style = lamb.utils.style

529
lamb/node.py Normal file
View File

@ -0,0 +1,529 @@
import enum
class Direction(enum.Enum):
UP = enum.auto()
LEFT = enum.auto()
RIGHT = enum.auto()
class ReductionType(enum.Enum):
# Nothing happened. This implies that
# an expression cannot be reduced further.
NOTHING = enum.auto()
# We replaced a macro with an expression.
MACRO_EXPAND = enum.auto()
# We turned a church numeral into an expression
AUTOCHURCH = enum.auto()
# We replaced a macro with a free variable.
MACRO_TO_FREE = enum.auto()
# We applied a function.
# This is the only type of "formal" reduction step.
FUNCTION_APPLY = enum.auto()
class ReductionError(Exception):
"""
Raised when we encounter an error while reducing.
These should be caught and elegantly presented to the user.
"""
def __init__(self, msg: str):
self.msg = msg
class TreeWalker:
"""
An iterator that walks the "outline" of a tree
defined by a chain of nodes.
It returns a tuple: (out_side, out)
out is the node we moved to,
out_side is the direction we came to the node from.
"""
def __init__(self, expr):
self.expr = expr
self.ptr = expr
self.from_side = Direction.UP
def __next__(self):
# This could be implemented without checking the node type,
# but there's no reason to do that.
# Maybe later?
if self.ptr is self.expr.parent:
raise StopIteration
out = self.ptr
out_side = self.from_side
if isinstance(self.ptr, EndNode):
self.from_side, self.ptr = self.ptr.go_up()
elif isinstance(self.ptr, Func):
if self.from_side == Direction.UP:
self.from_side, self.ptr = self.ptr.go_left()
elif self.from_side == Direction.LEFT:
self.from_side, self.ptr = self.ptr.go_up()
elif isinstance(self.ptr, Call):
if self.from_side == Direction.UP:
self.from_side, self.ptr = self.ptr.go_left()
elif self.from_side == Direction.LEFT:
self.from_side, self.ptr = self.ptr.go_right()
elif self.from_side == Direction.RIGHT:
self.from_side, self.ptr = self.ptr.go_up()
else:
raise TypeError(f"I don't know how to iterate a {type(self.ptr)}")
return out_side, out
class Node:
"""
Generic class for an element of an expression tree.
All nodes are subclasses of this.
"""
def __init__(self):
# The node this one is connected to.
# None if this is the top objects.
self.parent: Node | None = None
# What direction this is relative to the parent.
# Left of Right.
self.parent_side: Direction | None = None
# Left and right nodes, None if empty
self._left: Node | None = None
self._right: Node | None = None
def __iter__(self):
return TreeWalker(self)
def _set_parent(self, parent, side):
"""
Set this node's parent and parent side.
This method shouldn't be called explicitly unless
there's no other option. Use self.left and self.right instead.
"""
if (parent is not None) and (side is None):
raise Exception("If a node has a parent, it must have a direction.")
if (parent is None) and (side is not None):
raise Exception("If a node has no parent, it cannot have a direction.")
self.parent = parent
self.parent_side = side
return self
@property
def left(self):
return self._left
@left.setter
def left(self, node):
if node is not None:
node._set_parent(self, Direction.LEFT)
self._left = node
@property
def right(self):
return self._right
@right.setter
def right(self, node):
if node is not None:
node._set_parent(self, Direction.RIGHT)
self._right = node
def set_side(self, side: Direction, node):
"""
A wrapper around Node.left and Node.right that
automatically selects a side.
"""
if side == Direction.LEFT:
self.left = node
elif side == Direction.RIGHT:
self.right = node
else:
raise TypeError("Can only set left or right side.")
def go_left(self):
"""
Go down the left branch of this node.
Returns a tuple (from_dir, node)
from_dir is the direction from which we came INTO the next node.
node is the node on the left of this one.
"""
if self._left is None:
raise Exception("Can't go left when left is None")
return Direction.UP, self._left
def go_right(self):
"""
Go down the right branch of this node.
Returns a tuple (from_dir, node)
from_dir is the direction from which we came INTO the next node.
node is the node on the right of this one.
"""
if self._right is None:
raise Exception("Can't go right when right is None")
return Direction.UP, self._right
def go_up(self):
"""
Go up th the parent of this node.
Returns a tuple (from_dir, node)
from_dir is the direction from which we came INTO the parent.
node is the node above of this one.
"""
return self.parent_side, self.parent
def copy(self):
"""
Return a copy of this node.
parent, parent_side, left, and right should be left
as None, and will be filled later.
"""
raise NotImplementedError("Nodes MUST provide a `copy` method!")
def __str__(self) -> str:
return print_node(self)
def bind_variables(self, *, ban_macro_name = None):
return bind_variables(
self,
ban_macro_name = ban_macro_name
)
class EndNode(Node):
def print_value(self):
raise NotImplementedError("EndNodes MUST provide a `print_value` method!")
class ExpandableEndNode(EndNode):
def expand(self) -> tuple[ReductionType, Node]:
raise NotImplementedError("ExpandableEndNodes MUST provide an `expand` method!")
class FreeVar(EndNode):
def __init__(self, name: str):
self.name = name
def __repr__(self):
return f"<freevar {self.name}>"
def print_value(self):
return f"{self.name}"
def copy(self):
return FreeVar(self.name)
class Macro(ExpandableEndNode):
@staticmethod
def from_parse(results):
return Macro(results[0])
def __init__(self, name: str) -> None:
super().__init__()
self.name = name
self.left = None
self.right = None
def __repr__(self):
return f"<macro {self.name}>"
def print_value(self):
return self.name
def expand(self, *, macro_table = {}) -> tuple[ReductionType, Node]:
if self.name in macro_table:
return ReductionType.MACRO_EXPAND, clone(macro_table[self.name])
else:
return ReductionType.MACRO_TO_FREE, FreeVar(self.name)
def copy(self):
return Macro(self.name)
class Church(ExpandableEndNode):
@staticmethod
def from_parse(results):
return Church(int(results[0]))
def __init__(self, value: int) -> None:
super().__init__()
self.value = value
self.left = None
self.right = None
def __repr__(self):
return f"<church {self.value}>"
def print_value(self):
return str(self.value)
def expand(self) -> tuple[ReductionType, Node]:
f = Bound("f")
a = Bound("a")
chain = a
for i in range(self.value):
chain = Call(clone(f), clone(chain))
return (
ReductionType.AUTOCHURCH,
Func(f, Func(a, chain))
)
def copy(self):
return Church(self.value)
bound_counter = 0
class Bound(EndNode):
def __init__(self, name: str, *, forced_id = None):
self.name = name
global bound_counter
if forced_id is None:
self.identifier = bound_counter
bound_counter += 1
else:
self.identifier = forced_id
def copy(self):
return Bound(self.name, forced_id = self.identifier)
def __eq__(self, other):
if not isinstance(other, Bound):
raise TypeError(f"Cannot compare bound_variable with {type(other)}")
return self.identifier == other.identifier
def __repr__(self):
return f"<{self.name} {self.identifier}>"
def print_value(self):
return self.name
class Func(Node):
@staticmethod
def from_parse(result):
if len(result[0]) == 1:
return Func(
result[0][0],
result[1]
)
else:
return Func(
result[0].pop(0),
Func.from_parse(result)
)
def __init__(self, input: Macro | Bound, output: Node) -> None:
super().__init__()
self.input: Macro | Bound = input
self.left: Node = output
self.right: None = None
def __repr__(self):
return f"<func {self.input!r} {self.left!r}>"
def copy(self):
return Func(self.input, None) # type: ignore
class Call(Node):
@staticmethod
def from_parse(results):
if len(results) == 2:
return Call(
results[0],
results[1]
)
else:
this = Call(
results[0],
results[1]
)
return Call.from_parse(
[Call(
results[0],
results[1]
)] + results[2:]
)
def __init__(self, fn: Node, arg: Node) -> None:
super().__init__()
self.left: Node = fn
self.right: Node = arg
def __repr__(self):
return f"<call {self.left!r} {self.right!r}>"
def copy(self):
return Call(None, None) # type: ignore
def print_node(node: Node) -> str:
if not isinstance(node, Node):
raise TypeError(f"I don't know how to print a {type(node)}")
out = ""
for s, n in node:
if isinstance(n, EndNode):
out += n.print_value()
elif isinstance(n, Func):
if s == Direction.UP:
if isinstance(n.parent, Func):
out += n.input.name
else:
out += "λ" + n.input.name
if not isinstance(n.left, Func):
out += "."
elif isinstance(n, Call):
if s == Direction.UP:
out += "("
elif s == Direction.LEFT:
out += " "
elif s == Direction.RIGHT:
out += ")"
return out
def clone(node: Node):
if not isinstance(node, Node):
raise TypeError(f"I don't know what to do with a {type(node)}")
out = node.copy()
out_ptr = out # Stays one step behind ptr, in the new tree.
ptr = node
from_side = Direction.UP
if isinstance(node, EndNode):
return out
# We're not using a TreeWalker here because
# we need more control over our pointer when cloning.
while True:
if isinstance(ptr, EndNode):
from_side, ptr = ptr.go_up()
_, out_ptr = out_ptr.go_up()
elif isinstance(ptr, Func):
if from_side == Direction.UP:
from_side, ptr = ptr.go_left()
out_ptr.set_side(ptr.parent_side, ptr.copy())
_, out_ptr = out_ptr.go_left()
elif from_side == Direction.LEFT:
from_side, ptr = ptr.go_up()
_, out_ptr = out_ptr.go_up()
elif isinstance(ptr, Call):
if from_side == Direction.UP:
from_side, ptr = ptr.go_left()
out_ptr.set_side(ptr.parent_side, ptr.copy())
_, out_ptr = out_ptr.go_left()
elif from_side == Direction.LEFT:
from_side, ptr = ptr.go_right()
out_ptr.set_side(ptr.parent_side, ptr.copy())
_, out_ptr = out_ptr.go_right()
elif from_side == Direction.RIGHT:
from_side, ptr = ptr.go_up()
_, out_ptr = out_ptr.go_up()
if ptr is node.parent:
break
return out
def bind_variables(node: Node, *, ban_macro_name = None) -> None:
if not isinstance(node, Node):
raise TypeError(f"I don't know what to do with a {type(node)}")
bound_variables = {}
for s, n in node:
# If this expression is part of a macro,
# make sure we don't reference it inside itself.
if isinstance(n, Macro) and ban_macro_name is not None:
if n.name == ban_macro_name:
raise ReductionError("Macro cannot reference self")
if isinstance(n, Func):
if s == Direction.UP:
# Add this function's input to the table of bound variables.
# If it is already there, raise an error.
if (n.input.name in bound_variables):
raise ReductionError(f"Bound variable name conflict: \"{n.input.name}\"")
else:
bound_variables[n.input.name] = Bound(n.input.name)
n.input = bound_variables[n.input.name]
# If output is a macro, swap it with a bound variable.
if isinstance(n.left, Macro):
if n.left.name in bound_variables:
n.left = clone(bound_variables[n.left.name])
elif s == Direction.LEFT:
del bound_variables[n.input.name]
elif isinstance(n, Call):
if s == Direction.UP:
# Bind macros
if isinstance(n.left, Macro):
if n.left.name in bound_variables:
n.left = clone(bound_variables[n.left.name])
if isinstance(n.right, Macro):
if n.right.name in bound_variables:
n.right = clone(bound_variables[n.right.name])
# Apply a function.
# Returns the function's output.
def call_func(fn: Func, arg: Node):
for s, n in fn:
if isinstance(n, Bound) and (s == Direction.UP):
if n == fn.input:
if n.parent is None:
raise Exception("Tried to substitute a None bound variable.")
n.parent.set_side(n.parent_side, clone(arg)) # type: ignore
return clone(fn.left)
# Do a single reduction step
def reduce(node: Node, *, macro_table = {}) -> tuple[ReductionType, Node]:
if not isinstance(node, Node):
raise TypeError(f"I can't reduce a {type(node)}")
out = node
for s, n in out:
if isinstance(n, Call) and (s == Direction.UP):
if isinstance(n.left, Func):
if n.parent is None:
out = call_func(n.left, n.right)
out._set_parent(None, None)
else:
n.parent.set_side(
n.parent_side, # type: ignore
call_func(n.left, n.right)
)
return ReductionType.FUNCTION_APPLY, out
elif isinstance(n.left, ExpandableEndNode):
if isinstance(n.left, Macro):
r, n.left = n.left.expand(
macro_table = macro_table
)
else:
r, n.left = n.left.expand()
return r, out
return ReductionType.NOTHING, out

View File

@ -54,7 +54,7 @@ class LambdaParser:
(self.lp + self.pp_call + self.rp)
)
self.pp_command = pp.Suppress(":") + pp.Word(pp.alphas + "_") + pp.Word(pp.alphas + "_")[0, ...]
self.pp_command = pp.Suppress(":") + pp.Word(pp.alphas + "_") + pp.Word(pp.alphas + pp.nums + "_")[0, ...]
self.pp_all = (
@ -73,7 +73,7 @@ class LambdaParser:
action_func,
action_bound,
action_macro,
action_apply
action_call
):
self.make_parser()
@ -84,7 +84,7 @@ class LambdaParser:
self.pp_lambda_fun.set_parse_action(action_func)
self.pp_macro.set_parse_action(action_macro)
self.pp_bound.set_parse_action(action_bound)
self.pp_call.set_parse_action(action_apply)
self.pp_call.set_parse_action(action_call)
def parse_line(self, line: str):
return self.pp_all.parse_string(

View File

@ -1,14 +1,11 @@
from tkinter import E
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit import print_formatted_text as printf
import enum
import math
import time
import lamb.commands as commands
from lamb.parser import LambdaParser
import lamb.tokens as tokens
import lamb.utils as utils
import lamb
class StopReason(enum.Enum):
@ -16,22 +13,60 @@ class StopReason(enum.Enum):
LOOP_DETECTED = ("class:warn", "Loop detected")
MAX_EXCEEDED = ("class:err", "Too many reductions")
INTERRUPT = ("class:warn", "User interrupt")
RECURSION = ("class:err", "Python Recursion Error")
class MacroDef:
@staticmethod
def from_parse(result):
return MacroDef(
result[0].name,
result[1]
)
def __init__(self, label: str, expr: lamb.node.Node):
self.label = label
self.expr = expr
def __repr__(self):
return f"<{self.label} := {self.expr!r}>"
def __str__(self):
return f"{self.label} := {self.expr}"
def bind_variables(self, *, ban_macro_name = None):
return self.expr.bind_variables(
ban_macro_name = ban_macro_name
)
class Command:
@staticmethod
def from_parse(result):
return Command(
result[0],
result[1:]
)
def __init__(self, name, args):
self.name = name
self.args = args
class Runner:
def __init__(self, prompt_session: PromptSession, prompt_message):
def __init__(
self,
prompt_session: PromptSession,
prompt_message
):
self.macro_table = {}
self.prompt_session = prompt_session
self.prompt_message = prompt_message
self.parser = LambdaParser(
action_command = tokens.command.from_parse,
action_macro_def = tokens.macro_expression.from_parse,
action_church = tokens.church_num.from_parse,
action_func = tokens.lambda_func.from_parse,
action_bound = tokens.macro.from_parse,
action_macro = tokens.macro.from_parse,
action_apply = tokens.lambda_apply.from_parse
self.parser = lamb.parser.LambdaParser(
action_func = lamb.node.Func.from_parse,
action_bound = lamb.node.Macro.from_parse,
action_macro = lamb.node.Macro.from_parse,
action_call = lamb.node.Call.from_parse,
action_church = lamb.node.Church.from_parse,
action_macro_def = MacroDef.from_parse,
action_command = Command.from_parse
)
# Maximum amount of reductions.
@ -44,23 +79,27 @@ class Runner:
# a bound variable.
self.bound_variable_counter = 0
# Update iteration after this many iterations
# Make sure every place value has a non-zero digit
# so that all digits appear to be changing.
self.iter_update = 231
def prompt(self):
return self.prompt_session.prompt(message = self.prompt_message)
return self.prompt_session.prompt(
message = self.prompt_message
)
def parse(self, line):
e = self.parser.parse_line(line)
# Give the elements of this expression access to the runner.
# Runner must be set BEFORE variables are bound.
e.set_runner(self)
if isinstance(e, tokens.macro_expression):
if isinstance(e, MacroDef):
e.bind_variables(ban_macro_name = e.label)
else:
elif isinstance(e, lamb.node.Node):
e.bind_variables()
return e
def reduce_expression(self, expr: tokens.LambdaToken) -> None:
def reduce(self, node: lamb.node.Node) -> None:
# Reduction Counter.
# We also count macro (and church) expansions,
# and subtract those from the final count.
@ -68,68 +107,72 @@ class Runner:
macro_expansions = 0
stop_reason = StopReason.MAX_EXCEEDED
start_time = time.time()
while (self.reduction_limit is None) or (i < self.reduction_limit):
try:
r = expr.reduce()
except RecursionError:
stop_reason = StopReason.RECURSION
break
expr = r.output
# Show reduction count
if (i >= self.iter_update) and (i % self.iter_update == 0):
print(f" Reducing... {i}", end = "\r")
#print(expr)
#self.prompt()
try:
red_type, new_node = lamb.node.reduce(
node,
macro_table = self.macro_table
)
except KeyboardInterrupt:
stop_reason = StopReason.INTERRUPT
break
node = new_node
# If we can't reduce this expression anymore,
# it's in beta-normal form.
if not r.was_reduced:
if red_type == lamb.node.ReductionType.NOTHING:
stop_reason = StopReason.BETA_NORMAL
break
# Count reductions
#i += 1
if (
r.reduction_type == tokens.ReductionType.MACRO_EXPAND or
r.reduction_type == tokens.ReductionType.AUTOCHURCH
):
i += 1
if red_type == lamb.node.ReductionType.FUNCTION_APPLY:
macro_expansions += 1
else:
i += 1
if (
stop_reason == StopReason.BETA_NORMAL or
stop_reason == StopReason.LOOP_DETECTED
):
out_str = str(r.output) # type: ignore
if i >= self.iter_update:
# Clear reduction counter
print(" " * round(14 + math.log10(i)), end = "\r")
printf(FormattedText([
("class:result_header", f"\nExit reason: "),
stop_reason.value,
out_text = [
("class:result_header", f"\nRuntime: "),
("class:text", f"{time.time() - start_time:.03f} seconds"),
("class:result_header", f"\nMacro expansions: "),
("class:text", str(macro_expansions)),
("class:result_header", f"\nExit reason: "),
stop_reason.value,
("class:result_header", f"\nReductions: "),
("class:text", str(i)),
("class:result_header", f"\nMacro expansions: "),
("class:text", f"{macro_expansions:,}"),
("class:result_header", f"\nReductions: "),
("class:text", f"{i:,} "),
("class:muted", f"(Limit: {self.reduction_limit:,})")
]
if (stop_reason == StopReason.BETA_NORMAL or stop_reason == StopReason.LOOP_DETECTED):
out_text += [
("class:result_header", "\n\n => "),
("class:text", out_str),
]), style = utils.style)
else:
printf(FormattedText([
("class:result_header", f"\nExit reason: "),
stop_reason.value,
("class:text", str(new_node)), # type: ignore
]
("class:result_header", f"\nMacro expansions: "),
("class:text", str(macro_expansions)),
printf(
FormattedText(out_text),
style = lamb.utils.style
)
("class:result_header", f"\nReductions: "),
("class:text", str(i)),
]), style = utils.style)
def save_macro(self, macro: tokens.macro_expression, *, silent = False) -> None:
def save_macro(
self,
macro: MacroDef,
*,
silent = False
) -> None:
was_rewritten = macro.label in self.macro_table
self.macro_table[macro.label] = macro.expr
@ -139,23 +182,36 @@ class Runner:
("class:syn_macro", macro.label),
("class:text", " to "),
("class:text", str(macro.expr))
]), style = utils.style)
]), style = lamb.utils.style)
# Apply a list of definitions
def run(self, line: str, *, silent = False) -> None:
def run(
self,
line: str,
*,
silent = False
) -> None:
e = self.parse(line)
# If this line is a macro definition, save the macro.
if isinstance(e, tokens.macro_expression):
if isinstance(e, MacroDef):
self.save_macro(e, silent = silent)
# If this line is a command, do the command.
elif isinstance(e, tokens.command):
commands.run(e, self)
elif isinstance(e, Command):
if e.name not in lamb.commands.commands:
printf(
FormattedText([
("class:warn", f"Unknown command \"{e.name}\"")
]),
style = lamb.utils.style
)
else:
lamb.commands.commands[e.name](e, self)
# If this line is a plain expression, reduce it.
elif isinstance(e, tokens.LambdaToken):
self.reduce_expression(e)
elif isinstance(e, lamb.node.Node):
self.reduce(e)
# We shouldn't ever get here.
else:

View File

@ -1,599 +0,0 @@
import enum
import lamb.utils as utils
class ReductionError(Exception):
"""
Raised when we encounter an error while reducing.
These should be caught and elegantly presented to the user.
"""
def __init__(self, msg: str):
self.msg = msg
class ReductionType(enum.Enum):
MACRO_EXPAND = enum.auto()
MACRO_TO_FREE = enum.auto()
FUNCTION_APPLY = enum.auto()
AUTOCHURCH = enum.auto()
class ReductionStatus:
"""
This object helps organize reduction output.
An instance is returned after every reduction step.
"""
def __init__(
self,
*,
output,
was_reduced: bool,
reduction_type: ReductionType | None = None
):
# The new expression
self.output = output
# What did we do?
# Will be None if was_reduced is false.
self.reduction_type = reduction_type
# Did this reduction change anything?
# If we try to reduce an irreducible expression,
# this will be false.
self.was_reduced = was_reduced
class LambdaToken:
"""
Base class for all lambda tokens.
"""
def set_runner(self, runner):
self.runner = runner
def bind_variables(self, *, ban_macro_name: str | None = None) -> None:
pass
def reduce(self) -> ReductionStatus:
return ReductionStatus(
was_reduced = False,
output = self
)
class church_num(LambdaToken):
"""
Represents a Church numeral.
"""
@staticmethod
def from_parse(result):
return church_num(
int(result[0]),
)
def __init__(self, val):
self.val = val
def __repr__(self):
return f"<{self.val}>"
def __str__(self):
return f"{self.val}"
def to_church(self):
"""
Return this number as an expanded church numeral.
"""
f = bound_variable("f", runner = self.runner)
a = bound_variable("a", runner = self.runner)
chain = a
for i in range(self.val):
chain = lambda_apply(f, chain)
return lambda_func(
f,
lambda_func(a, chain)
)
def reduce(self, *, force_substitute = False) -> ReductionStatus:
if force_substitute: # Only expand macros if we NEED to
return ReductionStatus(
output = self.to_church(),
was_reduced = True,
reduction_type = ReductionType.AUTOCHURCH
)
else: # Otherwise, do nothing.
return ReductionStatus(
output = self,
was_reduced = False
)
class free_variable(LambdaToken):
"""
Represents a free variable.
This object does not reduce to
anything, since it has no meaning.
Any name in an expression that isn't
a macro or a bound variable is assumed
to be a free variable.
"""
def __init__(self, label: str):
self.label = label
def __repr__(self):
return f"<freevar {self.label}>"
def __str__(self):
return f"{self.label}"
class command(LambdaToken):
@staticmethod
def from_parse(result):
return command(
result[0],
result[1:]
)
def __init__(self, name, args):
self.name = name
self.args = args
class macro(LambdaToken):
"""
Represents a "macro" in lambda calculus,
a variable that reduces to an expression.
These don't have any inherent logic, they
just make writing and reading expressions
easier.
These are defined as follows:
<macro name> = <expression>
"""
@staticmethod
def from_parse(result):
return macro(
result[0],
)
def __init__(self, name):
self.name = name
def __repr__(self):
return f"<{self.name}>"
def __str__(self):
return self.name
def __eq__(self, other):
if not isinstance(other, macro):
raise TypeError("Can only compare macro with macro")
return self.name == other.name
def bind_variables(self, *, ban_macro_name=None) -> None:
if self.name == ban_macro_name:
raise ReductionError(f"Cannot use macro \"{ban_macro_name}\" here.")
def reduce(
self,
*,
# To keep output readable, we avoid expanding macros as often as possible.
# Macros are irreducible if force_substitute is false.
force_substitute = False,
# If this is false, error when macros aren't defined instead of
# invisibly making a free variable.
auto_free_vars = True
) -> ReductionStatus:
if (self.name in self.runner.macro_table) and force_substitute:
if force_substitute: # Only expand macros if we NEED to
return ReductionStatus(
output = self.runner.macro_table[self.name],
reduction_type = ReductionType.MACRO_EXPAND,
was_reduced = True
)
else: # Otherwise, do nothing.
return ReductionStatus(
output = self,
was_reduced = False
)
elif not auto_free_vars:
raise ReductionError(f"Macro {self.name} is not defined")
else:
return ReductionStatus(
output = free_variable(self.name),
reduction_type = ReductionType.MACRO_TO_FREE,
was_reduced = True
)
class macro_expression(LambdaToken):
"""
Represents a line that looks like
<name> = <expression>
Doesn't do anything particularly interesting,
just holds an expression until it is stored
in the runner's macro table.
"""
@staticmethod
def from_parse(result):
return macro_expression(
result[0].name,
result[1]
)
def set_runner(self, runner):
self.expr.set_runner(runner)
def bind_variables(self, *, ban_macro_name: str | None = None):
self.expr.bind_variables(ban_macro_name = ban_macro_name)
def __init__(self, label: str, expr: LambdaToken):
self.label = label
self.expr = expr
def __repr__(self):
return f"<{self.label} := {self.expr!r}>"
def __str__(self):
return f"{self.label} := {self.expr}"
class bound_variable(LambdaToken):
def __init__(self, name: str, *, runner, forced_id = None):
self.original_name = name
self.runner = runner
if forced_id is None:
self.identifier = self.runner.bound_variable_counter
self.runner.bound_variable_counter += 1
else:
self.identifier = forced_id
def __eq__(self, other):
if not isinstance(other, bound_variable):
raise TypeError(f"Cannot compare bound_variable with {type(other)}")
return self.identifier == other.identifier
def __repr__(self):
return f"<{self.original_name} {self.identifier}>"
def __str__(self):
return self.original_name
class lambda_func(LambdaToken):
"""
Represents a function.
Defined like λa.aa
After being created by the parser, a function
needs to have its variables bound. This cannot
happen during parsing, since the parser creates
functions "inside-out," and we need all inner
functions before we bind variables.
"""
@staticmethod
def from_parse(result):
if len(result[0]) == 1:
return lambda_func(
result[0][0],
result[1]
)
else:
return lambda_func(
result[0].pop(0),
lambda_func.from_parse(result)
)
def set_runner(self, runner):
self.runner = runner
self.input.set_runner(runner)
self.output.set_runner(runner)
def __init__(
self,
input_var: macro | bound_variable,
output: LambdaToken
):
self.input: macro | bound_variable = input_var
self.output: LambdaToken = output
def __repr__(self) -> str:
return f"<{self.input!r}{self.output!r}>"
def __str__(self) -> str:
return f"λ{self.input}.{self.output}"
def bind_variables(
self,
placeholder: macro | None = None,
val: bound_variable | None = None,
*,
binding_self: bool = False,
ban_macro_name: str | None = None
) -> None:
"""
Go through this function and all the functions inside it,
and replace the strings generated by the parser with bound
variables or free variables.
If values are passed to `placeholder` and `val,`
we're binding the variable of a function containing
this one. If they are both none, start the binding
chain with this function.
If only one of those arguments is None, something is very wrong.
`placeholder` is a macro, NOT A STRING!
The parser assumes all names are macros at first, variable
binding fixes those that are actually bound variables.
If `binding_self` is True, don't throw an error on a name conflict
and don't bind this function's input variable.
This is used when we're calling this method to bind this function's
variable.
"""
if (placeholder is None) and (val != placeholder):
raise Exception(
"Error while binding variables: placeholder and val are both None."
)
# We only need to check for collisions if we're
# binding another function's variable. If this
# function starts the bind chain, skip that step.
if placeholder is not None:
if not binding_self and isinstance(self.input, macro):
if self.input == placeholder:
raise ReductionError(f"Bound variable name conflict: \"{self.input.name}\"")
if self.input.name in self.runner.macro_table:
raise ReductionError(f"Bound variable name conflict: \"{self.input.name}\" is a macro")
# If this function's variables haven't been bound yet,
# bind them BEFORE binding the outer function's.
#
# If we bind inner functions' variables before outer
# functions' variables, we won't be able to detect
# name conflicts.
if isinstance(self.input, macro) and not binding_self:
new_bound_var = bound_variable(
self.input.name,
runner = self.runner
)
self.bind_variables(
self.input,
new_bound_var,
binding_self = True,
ban_macro_name = ban_macro_name
)
self.input = new_bound_var
# Bind variables inside this function.
if isinstance(self.output, macro) and placeholder is not None:
if self.output.name == ban_macro_name:
raise ReductionError(f"Cannot use macro \"{ban_macro_name}\" here.")
if self.output == placeholder:
self.output = val # type: ignore
elif isinstance(self.output, lambda_func):
self.output.bind_variables(placeholder, val, ban_macro_name = ban_macro_name)
elif isinstance(self.output, lambda_apply):
self.output.bind_variables(placeholder, val, ban_macro_name = ban_macro_name)
def reduce(self) -> ReductionStatus:
r = self.output.reduce()
return ReductionStatus(
was_reduced = r.was_reduced,
reduction_type = r.reduction_type,
output = lambda_func(
self.input,
r.output
)
)
def apply(
self,
val,
*,
bound_var: bound_variable | None = None
):
"""
Substitute `bound_var` into all instances of a bound variable `var`.
If `bound_var` is none, use this functions bound variable.
Returns a new object.
"""
calling_self = False
if bound_var is None:
calling_self = True
bound_var = self.input # type: ignore
new_out = self.output
if isinstance(self.output, bound_variable):
if self.output == bound_var:
new_out = val
elif isinstance(self.output, lambda_func):
new_out = self.output.apply(val, bound_var = bound_var)
elif isinstance(self.output, lambda_apply):
new_out = self.output.sub_bound_var(val, bound_var = bound_var) # type: ignore
# If we're applying THIS function,
# just give the output
if calling_self:
return new_out
# If we're applying another function,
# return this one with substitutions
else:
return lambda_func(
self.input,
new_out
)
class lambda_apply(LambdaToken):
"""
Represents a function application.
Has two elements: fn, the function,
and arg, the thing it acts upon.
Parentheses are handled by the parser, and
chained functions are handled by from_parse.
"""
@staticmethod
def from_parse(result):
if len(result) == 2:
return lambda_apply(
result[0],
result[1]
)
elif len(result) > 2:
return lambda_apply.from_parse([
lambda_apply(
result[0],
result[1]
)] + result[2:]
)
def set_runner(self, runner):
self.runner = runner
self.fn.set_runner(runner)
self.arg.set_runner(runner)
def __init__(
self,
fn: LambdaToken,
arg: LambdaToken
):
self.fn: LambdaToken = fn
self.arg: LambdaToken = arg
def __repr__(self) -> str:
return f"<{self.fn!r} | {self.arg!r}>"
def __str__(self) -> str:
return f"({self.fn} {self.arg})"
def bind_variables(
self,
placeholder: macro | None = None,
val: bound_variable | None = None,
*,
ban_macro_name: str | None = None
) -> None:
"""
Does exactly what lambda_func.bind_variables does,
but acts on applications instead.
"""
if (placeholder is None) and (val != placeholder):
raise Exception(
"Error while binding variables: placeholder and val are both None."
)
# If val and placeholder are None,
# everything below should still work as expected.
if isinstance(self.fn, macro) and placeholder is not None:
if self.fn.name == ban_macro_name:
raise ReductionError(f"Cannot use macro \"{ban_macro_name}\" here.")
if self.fn == placeholder:
self.fn = val # type: ignore
elif isinstance(self.fn, lambda_func):
self.fn.bind_variables(placeholder, val, ban_macro_name = ban_macro_name)
elif isinstance(self.fn, lambda_apply):
self.fn.bind_variables(placeholder, val, ban_macro_name = ban_macro_name)
if isinstance(self.arg, macro) and placeholder is not None:
if self.arg.name == ban_macro_name:
raise ReductionError(f"Cannot use macro \"{ban_macro_name}\" here.")
if self.arg == placeholder:
self.arg = val # type: ignore
elif isinstance(self.arg, lambda_func):
self.arg.bind_variables(placeholder, val, ban_macro_name = ban_macro_name)
elif isinstance(self.arg, lambda_apply):
self.arg.bind_variables(placeholder, val, ban_macro_name = ban_macro_name)
def sub_bound_var(
self,
val,
*,
bound_var: bound_variable
):
new_fn = self.fn
if isinstance(self.fn, bound_variable):
if self.fn == bound_var:
new_fn = val
elif isinstance(self.fn, lambda_func):
new_fn = self.fn.apply(val, bound_var = bound_var)
elif isinstance(self.fn, lambda_apply):
new_fn = self.fn.sub_bound_var(val, bound_var = bound_var)
new_arg = self.arg
if isinstance(self.arg, bound_variable):
if self.arg == bound_var:
new_arg = val
elif isinstance(self.arg, lambda_func):
new_arg = self.arg.apply(val, bound_var = bound_var)
elif isinstance(self.arg, lambda_apply):
new_arg = self.arg.sub_bound_var(val, bound_var = bound_var)
return lambda_apply(
new_fn,
new_arg
)
def reduce(self) -> ReductionStatus:
# If we can directly apply self.fn, do so.
if isinstance(self.fn, lambda_func):
return ReductionStatus(
was_reduced = True,
reduction_type = ReductionType.FUNCTION_APPLY,
output = self.fn.apply(self.arg)
)
# Otherwise, try to reduce self.fn.
# If that is impossible, try to reduce self.arg.
else:
if isinstance(self.fn, macro) or isinstance(self.fn, church_num):
# Macros must be reduced before we apply them as functions.
# This is the only place we force substitution.
r = self.fn.reduce(
force_substitute = True
)
else:
r = self.fn.reduce()
if r.was_reduced:
return ReductionStatus(
was_reduced = True,
reduction_type = r.reduction_type,
output = lambda_apply(
r.output,
self.arg
)
)
else:
r = self.arg.reduce()
return ReductionStatus(
was_reduced = r.was_reduced,
reduction_type = r.reduction_type,
output = lambda_apply(
self.fn,
r.output
)
)

View File

@ -11,6 +11,7 @@ style = Style.from_dict({ # type: ignore
"err": "#FF0000",
"prompt": "#00FFFF",
"ok": "#B4EC85",
"muted": "#AAAAAA",
# Syntax
"syn_macro": "#FF00FF",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
misc/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -1,7 +1,21 @@
[project]
name = "Lamb"
description = "A lambda calculus engine"
version = "0.0.0"
# We use the standard semantic versioning:
# maj.min.pat
#
# Major release:
# 1.0.0 is the first stable release.
# Incremented on BIG breaking changes.
#
# Minor release:
# Large bug fixes, new features
#
# Patch release:
# Small, compatible fixes.
version = "0.1.0"
dependencies = [
"prompt-toolkit==3.0.31",
"pyparsing==3.0.9"