Compare commits

..

56 Commits

Author SHA1 Message Date
5bff77e4a7 Merge branch 'master' of https://github.com/rm-dr/lamb 2023-11-27 14:23:00 -08:00
8c8ea69890 Update README.md 2023-11-27 14:21:13 -08:00
6d14333e52 Update README.md 2023-11-27 14:19:24 -08:00
ab9057148a Merge branch 'master' of ssh://git.betalupi.com:33/Mark/lamb 2023-10-17 18:37:38 -07:00
c07edf1b50 Updated README.md 2023-10-16 21:17:27 -07:00
1bb0b91e20 Fixed a link in README 2023-10-16 15:31:49 -07:00
b20604de5c Fixed a path 2023-10-10 22:08:07 -07:00
d2f8b1c8fb Updated version 2023-04-02 20:54:26 -07:00
3022c2ffc0 Removed type hints to support older python versions 2023-04-02 20:52:59 -07:00
acbc247e10 Merge branch 'master' of ssh://git.betalupi.com:33/Mark/lamb 2023-04-02 07:56:55 -07:00
907d2d9e79 Added links to readme 2023-04-02 07:55:37 -07:00
73f4c60c06 Updated install instructions 2023-03-30 20:29:29 -07:00
fe7e6fca13 Added social media banner 2023-01-09 17:36:49 -08:00
866cb64485 Updated links 2023-01-09 17:15:52 -08:00
09a389857a Hide time when reducing by steps 2022-11-12 19:31:12 -08:00
da997b80c7 Added demo script 2022-11-12 19:15:08 -08:00
1b951813f4 Fixed command parser 2022-11-12 19:14:58 -08:00
12d6176f63 Updated README 2022-11-12 18:56:29 -08:00
787dbd9091 Added vhs tape 2022-11-12 18:16:35 -08:00
67b2332e1c Removed screenshot 2022-11-12 18:16:23 -08:00
115b7289e7 Updated README 2022-11-12 18:07:04 -08:00
69bf43f295 Updated README 2022-11-12 18:02:19 -08:00
495c947441 More reliable ctrl-c 2022-11-12 17:47:41 -08:00
b26d968884 Notes 2022-11-11 18:32:40 -08:00
e8bd6997b9 Added support for arguments 2022-11-11 18:31:37 -08:00
97aecb01f0 Fixed build files 2022-11-11 18:19:38 -08:00
45493c1093 Renamed package for pypi 2022-11-11 17:20:59 -08:00
a30bd7b870 Updated screenshot 2022-11-11 17:11:09 -08:00
f3d02721ad Version & README 2022-11-11 17:07:45 -08:00
48e7d405dd Improved history & highlighting 2022-11-11 17:04:05 -08:00
78cc118bfc Added :expand 2022-11-11 15:45:22 -08:00
dfbc5f3704 Added :step and fixed cloning bug 2022-11-11 15:30:21 -08:00
bd13b10f76 Added subscript parsing 2022-11-10 09:08:58 -08:00
0c215a5df4 Fixed print method 2022-11-10 08:02:28 -08:00
39d32a9925 Updated README 2022-11-09 21:54:10 -08:00
89bc4bd2c0 Minor warning cleanup 2022-11-09 21:47:55 -08:00
062a4b4d1b Updated README 2022-11-09 21:44:58 -08:00
4e3d5fa341 Fixed a macro bug 2022-11-09 21:44:32 -08:00
04ce18c891 Updated README 2022-11-07 20:18:33 -08:00
9f467fa223 Cleaned up files 2022-11-07 20:15:04 -08:00
231c873b1c File cleanup 2022-11-07 19:58:11 -08:00
ac08c5be59 Spelling 2022-11-07 19:02:35 -08:00
ab682ef700 Fixed :save & added new commands 2022-11-07 19:02:27 -08:00
cf2d82acc6 README 2022-11-01 21:33:01 -07:00
60823f1922 Color fix 2022-11-01 21:31:18 -07:00
740c562dbe Colors and small fixes 2022-11-01 21:29:39 -07:00
d88a7ff77f README 2022-11-01 20:48:43 -07:00
d3917b1f58 Version 2022-11-01 20:47:01 -07:00
04880b7724 Did the following:
- Added Root node
  - Created node.prepare() method
  - Added "macro show" warning
  - Rewrote TreeWalker & simplified a few methods
2022-11-01 20:46:45 -07:00
ecbb8661ce Name cleanup 2022-10-31 08:27:56 -07:00
73b803a1b0 Minor cleanup 2022-10-31 08:27:28 -07:00
c7464076ff Improved full-reduction logic 2022-10-31 08:20:27 -07:00
33c8d5bb66 Updated README 2022-10-30 22:43:19 -07:00
8d05de58f7 Updated README 2022-10-29 15:45:27 -07:00
01e542f88c Added basic history recall 2022-10-29 15:44:17 -07:00
c58819a7d6 Cleaned up style 2022-10-29 15:43:59 -07:00
28 changed files with 2214 additions and 1452 deletions

6
.gitignore vendored
View File

@ -1,7 +1,13 @@
# Python dev files
venv
__pycache__
# Python build files
*.egg-info
*.spec
build
dist
# Misc
*.gif
misc/secrets.sh

View File

@ -1,10 +1,14 @@
{
"cSpell.words": [
"appendleft",
"autochurch",
"delmac",
"Endnodes",
"freevar",
"mdel",
"onefile",
"Packrat",
"printables",
"pyparsing",
"rlimit",
"runstatus",

122
README.md
View File

@ -1,93 +1,119 @@
# Lamb: A Lambda Calculus Engine
# 🐑 Lamb: A Lambda Calculus Engine
![Lamb screenshot](./misc/screenshot.png)
![Lamb Demo](https://github.com/rm-dr/lamb/assets/96270320/d518e344-e7c8-47ed-89c4-7ce273bf4e2d)
## Installation
### Method 1: PyPi (not yet)
1. Put this on PyPi
2. Write these instructions
## :brain: What is lambda calculus?
- [video 1](https://www.youtube.com/watch?v=3VQ382QG-y4): Introduction and boolean logic. The first few minutes are a bit confusing, but it starts to make sense at about [`6:50`](https://youtu.be/3VQ382QG-y4?t=400)
- [video 2](https://www.youtube.com/watch?v=pAnLQ9jwN-E): Continuation of video 1. Features combinators and numerals.
- [blog](https://www.driverlesscrocodile.com/technology/lambda-calculus-for-people-a-step-behind-me-1): Another introduction. Moves slower than the two videos above and doesn't assume CS knowledge. Four-part series.
- [handout](https://static.betalupi.com/ormc/Advanced/Lambda%20Calculus.pdf): A handout I've written on lambda calculus.
## :package: Installation
### Method 1: [PyPi](https://pypi.org/project/lamb-engine)
1. *(Optional but recommended)* make and enter a [venv](https://docs.python.org/3/library/venv.html)
- **On Windows, run the following in cmd or powershell:**
- `cd Desktop`
- `python -m venv lamb`
- `.\Scripts\activate`
2. `pip install lamb-engine`
3. `lamb`
### 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 .``
5. Run ``lamb``
-------------------------------------------------
## Usage
## 📖 Usage
Type lambda expressions into the prompt, and Lamb will evaluate them. \
Type 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
==> 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. Free variables will be shown with a `'`, like `a'`.
Free variables will be shown with a `'`, like `a'`.
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.
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
==> 3 NOT F
```
If an expression takes too long to evaluate, you may interrupt reduction with `Ctrl-C`.
If an expression takes too long to evaluate, you may interrupt reduction with `Ctrl-C`. \
Exit the prompt with `Ctrl-C` or `Ctrl-D`.
There are many useful macros in [macros.lamb](./macros.lamb). Download the file, then load them with the `:load` command:
```
==> :load macros.lamb
```
You can also pass files to lamb directly to have them loaded at startup:
```
lamb file1 file2
```
Use your up/down arrows to recall history.
Have fun!
-------------------------------------------------
## Commands
## :card_file_box: Commands
Lamb comes with a few commands. Prefix them with a `:`
Lamb understands many commands. Prefix them with a `:` in the prompt.
`:help` Prints a help message
`:help` Print 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.
`:macros` List macros.
`:mdel [macro]` Delete a macro
`:delmac` Delete all macros
`:step [yes | no]` Enable or disable step-by-step reduction. Toggle if no argument is given. When reducing by steps, the prompt tells you what kind of reduction was done last:
- `M`: Macro expansion
- `C`: Church expansion
- `H`: History expansion
- `F`: Function application
`:expand [yes | no]` Enable or disable full expansion. Toggle if no argument is given. If full expansion is enabled, ALL macros will be expanded when printing output.
`: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.
`:load [filename]` \
Save or load macros from a file.
The lines in a file look exactly the same as regular entries in the prompt, but can only contain macro definitions. See [macros.lamb](./macros.lamb) for an example.
-------------------------------------------------
## Internals
Lamb treats each λ expression as a binary tree. Variable binding and reduction are all simple operations on that tree. All this magic happens in [`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):
- Make command output accessible in prompt
- Prettier colors
- Prevent macro-chaining recursion
- step-by-step reduction
- Full-reduce option (expand all macros)
- Show a warning when a free variable is created
- PyPi package
## Todo:
- Better class mutation: when is a node no longer valid?
- Prevent macro-chaining recursion
- Cleanup warnings
- Truncate long expressions in warnings
- Loop detection
- Command-line options (load a file, run a set of commands)
- $\alpha$-equivalence check
- Unchurch macro: make church numerals human-readable
- Syntax highlighting: parenthesis, bound variables, macros, etc
- Unchurch command: make church numerals human-readable
- Better syntax highlighting
- Tab-complete file names and commands
- Load default macros without manually downloading `macros.lamb` (via `requests`, maybe?)

View File

@ -1,10 +0,0 @@
import PyInstaller.__main__
# Run this file to build a standalone executable.
# pyinstaller does not build cross-platform.
PyInstaller.__main__.run([
"lamb/__main__.py",
"--onefile",
"--console"
])

View File

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

View File

@ -1,98 +0,0 @@
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit import print_formatted_text as printf
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
# Simple lexer for highlighting.
# Improve this later.
class LambdaLexer(Lexer):
def lex_document(self, document):
def inner(line_no):
return [("class:text", str(document.lines[line_no]))]
return inner
lamb.utils.show_greeting()
# Replace "\" with pretty "λ"s
bindings = KeyBindings()
@bindings.add("\\")
def _(event):
event.current_buffer.insert_text("λ")
r = lamb.Runner(
prompt_session = PromptSession(
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 b F)",
"OR = λab.(a T b)",
"XOR = λab.(a (NOT b) b)",
"M = λx.(x x)",
"W = M M",
"Y = λf.( (λx.(f (x x))) (λx.(f (x x))) )",
"PAIR = λabi.( i a b )",
"S = λnfa.(f (n f a))",
"Z = λn.n (λa.F) T",
"MULT = λnmf.n (m f)",
"H = λp.((PAIR (p F)) (S (p F)))",
"D = λn.n H (PAIR 0 0) T",
"FAC = λyn.(Z n)(1)(MULT n (y (D n)))"
])
while True:
try:
i = r.prompt()
# Catch Ctrl-C and Ctrl-D
except KeyboardInterrupt:
printf("\n\nGoodbye.\n")
break
except EOFError:
printf("\n\nGoodbye.\n")
break
# Skip empty lines
if i.strip() == "":
continue
# Try to run an input line.
# Catch parse errors and point them out.
try:
x = r.run(i)
except ppx.ParseException as e:
l = len(to_plain_text(r.prompt_session.message))
printf(FormattedText([
("class:err", " "*(e.loc + l) + "^\n"),
("class:err", f"Syntax error at char {e.loc}."),
("class:text", "\n")
]), style = lamb.utils.style)
continue
except lamb.node.ReductionError as e:
printf(FormattedText([
("class:err", f"{e.msg}\n")
]), style = lamb.utils.style)
continue
printf("")

View File

@ -1,286 +0,0 @@
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.formatted_text import HTML
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
commands = {}
help_texts = {}
def lamb_command(
*,
command_name: str | None = None,
help_text: str
):
"""
A decorator that allows us to easily make commands
"""
def inner(func):
name = func.__name__ if command_name is None else command_name
commands[name] = func
help_texts[name] = help_text
return inner
@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(
f"<err>Command <cmd_code>:{command.name}</cmd_code> takes exactly one argument.</err>"
),
style = lamb.utils.style
)
return
target = command.args[0]
if os.path.exists(target):
confirm = runner.prompt_session.prompt(
message = FormattedText([
("class:warn", "File exists. Overwrite? "),
("class:text", "[yes/no]: ")
])
).lower()
if confirm != "yes":
printf(
HTML(
"<err>Cancelled.</err>"
),
style = lamb.utils.style
)
return
with open(target, "w") as f:
f.write("\n".join(
[f"{n} = {e.export()}" for n, e in runner.macro_table.items()]
))
printf(
HTML(
f"Wrote {len(runner.macro_table)} macros to <cmd_code>{target}</cmd_code>"
),
style = lamb.utils.style
)
@lamb_command(
command_name = "load",
help_text = "Load macros from a file"
)
def cmd_load(command, runner):
if 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
target = command.args[0]
if not os.path.exists(target):
printf(
HTML(
f"<err>File {target} doesn't exist.</err>"
),
style = lamb.utils.style
)
return
with open(target, "r") as f:
lines = [x.strip() for x in f.readlines()]
for i in range(len(lines)):
l = lines[i]
try:
x = runner.parse(l)
except ppx.ParseException as e:
printf(
FormattedText([
("class:warn", f"Syntax error on line {i+1:02}: "),
("class:cmd_code", l[:e.loc]),
("class:err", l[e.loc]),
("class:cmd_code", l[e.loc+1:])
]),
style = lamb.utils.style
)
return
if not isinstance(x, lamb.runner.MacroDef):
printf(
FormattedText([
("class:warn", f"Skipping line {i+1:02}: "),
("class:cmd_code", l),
("class:warn", f" is not a macro definition.")
]),
style = lamb.utils.style
)
return
runner.save_macro(x, silent = True)
printf(
FormattedText([
("class:ok", f"Loaded {x.label}: "),
("class:cmd_code", str(x.expr))
]),
style = lamb.utils.style
)
@lamb_command(
help_text = "Delete a macro"
)
def mdel(command, runner) -> None:
if 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
target = command.args[0]
if target not in runner.macro_table:
printf(
HTML(
f"<warn>Macro \"{target}\" is not defined</warn>"
),
style = lamb.utils.style
)
return
del runner.macro_table[target]
@lamb_command(
help_text = "Show macros"
)
def macros(command, runner) -> None:
printf(FormattedText([
("class:cmd_h", "\nDefined Macros:\n"),
] +
[
("class:cmd_text", f"\t{name} \t {exp}\n")
for name, exp in runner.macro_table.items()
]),
style = lamb.utils.style
)
@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
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." +
"\n" +
"\tMacros can be defined using <cmd_key>=</cmd_key>, as in <cmd_code>T = λab.a</cmd_code>" +
"\n" +
"\tRun commands using <cmd_key>:</cmd_key>, for example <cmd_code>:help</cmd_code>" +
"\n\n" +
"<cmd_h>Commands:</cmd_h>"+
"\n" +
"\n".join([
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
)

View File

@ -1,608 +0,0 @@
import enum
import lamb
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 get_side(self, side: Direction):
if side == Direction.LEFT:
return self.left
elif side == Direction.RIGHT:
return self.right
else:
raise TypeError("Can only get 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 export(self) -> str:
"""
Convert this tree to a parsable string.
"""
return print_node(self, export = True)
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, *, export: bool = False) -> str:
raise NotImplementedError("EndNodes MUST provide a `print_value` method!")
class ExpandableEndNode(EndNode):
def expand(self, *, macro_table = {}) -> 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, *, export: bool = False) -> str:
if export:
return f"{self.name}'"
else:
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, *, export: bool = False) -> str:
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, *, export: bool = False) -> str:
return str(self.value)
def expand(self, *, macro_table = {}) -> 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, *, export: bool = False) -> str:
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, *, export: bool = False) -> str:
if not isinstance(node, Node):
raise TypeError(f"I don't know how to print a {type(node)}")
out = ""
bound_subs = {}
for s, n in node:
if isinstance(n, EndNode):
if isinstance(n, Bound):
if n.identifier not in bound_subs.keys():
o = n.print_value(export = export)
if o in bound_subs.items():
i = 1
while o in bound_subs.items():
o = lamb.utils.subscript(i := i + 1)
bound_subs[n.identifier] = o
else:
bound_subs[n.identifier] = n.print_value()
out += bound_subs[n.identifier]
else:
out += n.print_value(export = export)
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 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):
r, n.left = n.left.expand(
macro_table = macro_table
)
return r, out
return ReductionType.NOTHING, out
# Expand all expandable end nodes.
def force_expand_macros(node: Node, *, macro_table = {}) -> tuple[int, Node]:
if not isinstance(node, Node):
raise TypeError(f"I can't reduce a {type(node)}")
out = clone(node)
ptr = out
from_side = Direction.UP
macro_expansions = 0
while True:
if isinstance(ptr, ExpandableEndNode):
if ptr.parent is None:
ptr = ptr.expand(macro_table = macro_table)[1]
out = ptr
ptr._set_parent(None, None)
else:
ptr.parent.set_side(
ptr.parent_side, # type: ignore
ptr.expand(macro_table = macro_table)[1]
)
ptr = ptr.parent.get_side(
ptr.parent_side # type: ignore
)
macro_expansions += 1
if isinstance(ptr, EndNode):
from_side, ptr = ptr.go_up()
elif isinstance(ptr, Func):
if from_side == Direction.UP:
from_side, ptr = ptr.go_left()
elif from_side == Direction.LEFT:
from_side, ptr = ptr.go_up()
elif isinstance(ptr, Call):
if from_side == Direction.UP:
from_side, ptr = ptr.go_left()
elif from_side == Direction.LEFT:
from_side, ptr = ptr.go_right()
elif from_side == Direction.RIGHT:
from_side, ptr = ptr.go_up()
if ptr is node.parent:
break
return macro_expansions, out # type: ignore

View File

@ -1,237 +0,0 @@
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
class StopReason(enum.Enum):
BETA_NORMAL = ("class:text", "β-normal form")
LOOP_DETECTED = ("class:warn", "Loop detected")
MAX_EXCEEDED = ("class:err", "Too many reductions")
INTERRUPT = ("class:warn", "User interrupt")
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
):
self.macro_table = {}
self.prompt_session = prompt_session
self.prompt_message = prompt_message
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.
# If None, no maximum is enforced.
# Must be at least 1.
self.reduction_limit: int | None = 1_000_000
# Ensure bound variables are unique.
# This is automatically incremented whenever we make
# 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
)
def parse(self, line):
e = self.parser.parse_line(line)
if isinstance(e, MacroDef):
e.bind_variables(ban_macro_name = e.label)
elif isinstance(e, lamb.node.Node):
e.bind_variables()
return e
def reduce(self, node: lamb.node.Node) -> None:
# Reduction Counter.
# We also count macro (and church) expansions,
# and subtract those from the final count.
i = 0
macro_expansions = 0
stop_reason = StopReason.MAX_EXCEEDED
start_time = time.time()
full_reduce = isinstance(node, lamb.node.ExpandableEndNode)
out_text = []
while (self.reduction_limit is None) or (i < self.reduction_limit):
# Show reduction count
if (i >= self.iter_update) and (i % self.iter_update == 0):
print(f" Reducing... {i:,}", end = "\r")
try:
red_type, node = lamb.node.reduce(
node,
macro_table = self.macro_table
)
except KeyboardInterrupt:
stop_reason = StopReason.INTERRUPT
break
# If we can't reduce this expression anymore,
# it's in beta-normal form.
if red_type == lamb.node.ReductionType.NOTHING:
stop_reason = StopReason.BETA_NORMAL
break
# Count reductions
i += 1
if red_type == lamb.node.ReductionType.FUNCTION_APPLY:
macro_expansions += 1
# Expand all macros if we need to
if full_reduce:
m, node = lamb.node.force_expand_macros(
node,
macro_table = self.macro_table
)
macro_expansions += m
if i >= self.iter_update:
# Clear reduction counter
print(" " * round(14 + math.log10(i)), end = "\r")
out_text += [
("class:result_header", f"Runtime: "),
("class:text", f"{time.time() - start_time:.03f} seconds"),
("class:result_header", f"\nExit reason: "),
stop_reason.value,
("class:result_header", f"\nMacro expansions: "),
("class:text", f"{macro_expansions:,}"),
("class:result_header", f"\nReductions: "),
("class:text", f"{i:,}\t"),
("class:muted", f"(Limit: {self.reduction_limit:,})")
]
if full_reduce:
out_text += [
("class:warn", "\nAll macros have been expanded")
]
if (stop_reason == StopReason.BETA_NORMAL or stop_reason == StopReason.LOOP_DETECTED):
out_text += [
("class:result_header", "\n\n => "),
("class:text", str(node)), # type: ignore
]
printf(
FormattedText(out_text),
style = lamb.utils.style
)
def save_macro(
self,
macro: MacroDef,
*,
silent = False
) -> None:
was_rewritten = macro.label in self.macro_table
self.macro_table[macro.label] = macro.expr
if not silent:
printf(FormattedText([
("class:text", "Set "),
("class:syn_macro", macro.label),
("class:text", " to "),
("class:text", str(macro.expr))
]), style = lamb.utils.style)
# Apply a list of definitions
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, MacroDef):
self.save_macro(e, silent = silent)
# If this line is a command, do the command.
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, lamb.node.Node):
self.reduce(e)
# We shouldn't ever get here.
else:
raise TypeError(f"I don't know what to do with a {type(e)}")
def run_lines(self, lines: list[str]):
for l in lines:
self.run(l, silent = True)

View File

@ -1,104 +0,0 @@
from prompt_toolkit.styles import Style
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit import print_formatted_text as printf
from importlib.metadata import version
style = Style.from_dict({ # type: ignore
# Basic formatting
"text": "#FFFFFF",
"warn": "#FFFF00",
"err": "#FF0000",
"prompt": "#00FFFF",
"ok": "#B4EC85",
"muted": "#AAAAAA",
# Syntax
"syn_macro": "#FF00FF",
"syn_lambda": "#FF00FF",
"syn_bound": "#FF00FF",
# Titles for reduction results
"result_header": "#B4EC85 bold",
# Command formatting
# cmd_h: section titles
# cmd_code: example snippets
# cmd_text: regular text
# cmd_key: keyboard keys, usually one character
"cmd_h": "#FF6600 bold",
"cmd_code": "#AAAAAA italic",
"cmd_text": "#FFFFFF",
"cmd_key": "#B4EC85 bold",
# Only used in greeting
"_v": "#B4EC85 bold",
"_l": "#FF6600 bold",
"_s": "#B4EC85 bold",
"_p": "#AAAAAA"
})
def show_greeting():
# | _.._ _.|_
# |_(_|| | ||_)
# 0.0.0
#
# __ __
# ,-` `` `,
# (` \ )
# (` \ `)
# (, / \ _)
# (` / \ )
# `'._.--._.'
#
# A λ calculus engine
printf(HTML("\n".join([
"",
"<_h> | _.._ _.|_",
" |_(_|| | ||_)</_h>",
f" <_v>{version('lamb')}</_v>",
" __ __",
" ,-` `` `,",
" (` <_l>\\</_l> )",
" (` <_l>\\</_l> `)",
" (, <_l>/ \\</_l> _)",
" (` <_l>/ \\</_l> )",
" `'._.--._.'",
"",
"<_s> A λ calculus engine</_s>",
"<_p> Type :help for help</_p>",
""
])), style = style)
def subscript(num: int):
sub = {
"0": "",
"1": "",
"2": "",
"3": "",
"4": "",
"5": "",
"6": "",
"7": "",
"8": "",
"9": ""
}
sup = {
"0": "",
"1": "¹",
"2": "²",
"3": "³",
"4": "",
"5": "",
"6": "",
"7": "",
"8": "",
"9": ""
}
return "".join(
[sup[x] for x in str(num)]
)

8
lamb_engine/__init__.py Normal file
View File

@ -0,0 +1,8 @@
from . import utils
from . import nodes
from . import parser
from .runner import Runner
from .runner import StopReason
from .__main__ import main

79
lamb_engine/__main__.py Executable file
View File

@ -0,0 +1,79 @@
from prompt_toolkit import PromptSession
from prompt_toolkit import print_formatted_text as printf
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.formatted_text import to_plain_text
from pyparsing import exceptions as ppx
import sys
import lamb_engine
def main():
lamb_engine.utils.show_greeting()
r = lamb_engine.Runner(
prompt_session = PromptSession(
style = lamb_engine.utils.style,
lexer = lamb_engine.utils.LambdaLexer(),
key_bindings = lamb_engine.utils.bindings
),
prompt_message = FormattedText([
("class:prompt", "==> ")
])
)
# Load files passed as arguments
if len(sys.argv) > 1:
for i in range(1, len(sys.argv)):
try:
printf(FormattedText([
("class:warn", "\nLoading file "),
("class:code", sys.argv[i]),
]), style = lamb_engine.utils.style)
r.run(":load " + sys.argv[i])
except:
printf(FormattedText([
("class:err", "Error. Does this file exist?"),
]), style = lamb_engine.utils.style)
print("")
while True:
try:
i = r.prompt()
# Catch Ctrl-C and Ctrl-D
except KeyboardInterrupt:
printf("\n\nGoodbye.\n")
break
except EOFError:
printf("\n\nGoodbye.\n")
break
# Skip empty lines
if i.strip() == "":
continue
# Try to run an input line.
# Catch parse errors and point them out.
try:
x = r.run(i)
except ppx.ParseException as e:
l = len(to_plain_text(r.prompt_session.message))
printf(FormattedText([
("class:err", " "*(e.loc + l) + "^\n"),
("class:err", f"Syntax error at char {e.loc}."),
("class:text", "\n")
]), style = lamb_engine.utils.style)
continue
except lamb_engine.nodes.ReductionError as e:
printf(FormattedText([
("class:err", f"{e.msg}\n")
]), style = lamb_engine.utils.style)
continue
printf("")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,3 @@
from .misc import *
from .nodes import *
from .functions import *

View File

@ -0,0 +1,287 @@
import lamb_engine
import lamb_engine.nodes as lbn
def print_node(node: lbn.Node, *, export: bool = False) -> str:
if not isinstance(node, lbn.Node):
raise TypeError(f"I don't know how to print a {type(node)}")
out = ""
bound_subs = {}
for s, n in node:
if isinstance(n, lbn.EndNode):
if isinstance(n, lbn.Bound):
out += bound_subs[n.identifier]
else:
out += n.print_value(export = export)
elif isinstance(n, lbn.Func):
# This should never be true, but
# keep this here to silence type checker.
if not isinstance(n.input, lbn.Bound):
raise Exception("input is macro, something is wrong.")
if s == lbn.Direction.UP:
o = n.input.print_value(export = export)
if o in bound_subs.values():
i = -1
p = o
while o in bound_subs.values():
o = p + lamb_engine.utils.subscript(i := i + 1)
bound_subs[n.input.identifier] = o
else:
bound_subs[n.input.identifier] = n.input.print_value()
if isinstance(n.parent, lbn.Call):
out += "("
if isinstance(n.parent, lbn.Func):
out += bound_subs[n.input.identifier]
else:
out += "λ" + bound_subs[n.input.identifier]
if not isinstance(n.left, lbn.Func):
out += "."
elif s == lbn.Direction.LEFT:
if isinstance(n.parent, lbn.Call):
out += ")"
del bound_subs[n.input.identifier]
elif isinstance(n, lbn.Call):
if s == lbn.Direction.UP:
out += "("
elif s == lbn.Direction.LEFT:
out += " "
elif s == lbn.Direction.RIGHT:
out += ")"
return out
def clone(node: lbn.Node):
if not isinstance(node, lbn.Node):
raise TypeError(f"I don't know what to do with a {type(node)}")
macro_map = {}
if isinstance(node, lbn.Func):
c = node.copy()
macro_map[node.input.identifier] = c.input.identifier # type: ignore
else:
c = node.copy()
out = c
out_ptr = out # Stays one step behind ptr, in the new tree.
ptr = node
from_side = lbn.Direction.UP
if isinstance(node, lbn.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, lbn.EndNode):
from_side, ptr = ptr.go_up()
_, out_ptr = out_ptr.go_up()
elif isinstance(ptr, lbn.Func) or isinstance(ptr, lbn.Root):
if from_side == lbn.Direction.UP:
from_side, ptr = ptr.go_left()
if isinstance(ptr, lbn.Func):
c = ptr.copy()
macro_map[ptr.input.identifier] = c.input.identifier # type: ignore
elif isinstance(ptr, lbn.Bound):
c = ptr.copy()
if c.identifier in macro_map:
c.identifier = macro_map[c.identifier]
else:
c = ptr.copy()
out_ptr.set_side(ptr.parent_side, c)
_, out_ptr = out_ptr.go_left()
elif from_side == lbn.Direction.LEFT:
from_side, ptr = ptr.go_up()
_, out_ptr = out_ptr.go_up()
elif isinstance(ptr, lbn.Call):
if from_side == lbn.Direction.UP:
from_side, ptr = ptr.go_left()
if isinstance(ptr, lbn.Func):
c = ptr.copy()
macro_map[ptr.input.identifier] = c.input.identifier # type: ignore
elif isinstance(ptr, lbn.Bound):
c = ptr.copy()
if c.identifier in macro_map:
c.identifier = macro_map[c.identifier]
else:
c = ptr.copy()
out_ptr.set_side(ptr.parent_side, c)
_, out_ptr = out_ptr.go_left()
elif from_side == lbn.Direction.LEFT:
from_side, ptr = ptr.go_right()
if isinstance(ptr, lbn.Func):
c = ptr.copy()
macro_map[ptr.input.identifier] = c.input.identifier # type: ignore
elif isinstance(ptr, lbn.Bound):
c = ptr.copy()
if c.identifier in macro_map:
c.identifier = macro_map[c.identifier]
else:
c = ptr.copy()
out_ptr.set_side(ptr.parent_side, c)
_, out_ptr = out_ptr.go_right()
elif from_side == lbn.Direction.RIGHT:
from_side, ptr = ptr.go_up()
_, out_ptr = out_ptr.go_up()
if ptr is node.parent:
break
return out
def prepare(root: lbn.Root, *, ban_macro_name = None) -> list:
"""
Prepare an expression for expansion.
This will does the following:
- Binds variables
- Turns unbound macros into free variables
- Generates warnings
"""
if not isinstance(root, lbn.Root):
raise TypeError(f"I don't know what to do with a {type(root)}")
bound_variables = {}
warnings = []
it = iter(root)
for s, n in it:
if isinstance(n, lbn.History):
if root.runner.history[0] == None:
raise lbn.ReductionError("There isn't any history to reference.")
else:
warnings += [
("class:code", "$"),
("class:warn", " will be expanded to ")
] + lamb_engine.utils.lex_str(str(n.expand()[1]))
# If this expression is part of a macro,
# make sure we don't reference it inside itself.
elif isinstance(n, lbn.Macro):
if (n.name == ban_macro_name) and (ban_macro_name is not None):
raise lbn.ReductionError("Macro cannot reference self")
# Bind variables
if n.name in bound_variables:
n.parent.set_side(
n.parent_side,
clone(bound_variables[n.name])
)
it.ptr = n.parent.get_side(n.parent_side)
# Turn undefined macros into free variables
elif n.name not in root.runner.macro_table:
warnings += [
("class:warn", "Name "),
("class:code", n.name),
("class:warn", " is a free variable\n"),
]
n.parent.set_side(
n.parent_side,
n.to_freevar()
)
it.ptr = n.parent.get_side(n.parent_side)
# Save bound variables when we enter a function's sub-tree,
# delete them when we exit it.
elif isinstance(n, lbn.Func):
if s == lbn.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 lbn.ReductionError(f"Bound variable name conflict: \"{n.input.name}\"")
else:
bound_variables[n.input.name] = lbn.Bound(
lamb_engine.utils.remove_sub(n.input.name),
macro_name = n.input.name
)
n.input = bound_variables[n.input.name]
elif s == lbn.Direction.LEFT:
del bound_variables[n.input.macro_name] # type: ignore
return warnings
# Apply a function.
# Returns the function's output.
def call_func(fn: lbn.Func, arg: lbn.Node):
for s, n in fn:
if isinstance(n, lbn.Bound) and (s == lbn.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 fn.left
# Do a single reduction step
def reduce(root: lbn.Root) -> tuple[lbn.ReductionType, lbn.Root]:
if not isinstance(root, lbn.Root):
raise TypeError(f"I can't reduce a {type(root)}")
out = root
for s, n in out:
if isinstance(n, lbn.Call) and (s == lbn.Direction.UP):
if isinstance(n.left, lbn.Func):
n.parent.set_side(
n.parent_side, # type: ignore
call_func(n.left, n.right)
)
return lbn.ReductionType.FUNCTION_APPLY, out
elif isinstance(n.left, lbn.ExpandableEndNode):
r, n.left = n.left.expand()
return r, out
return lbn.ReductionType.NOTHING, out
def expand(root: lbn.Root, *, force_all = False) -> tuple[int, lbn.Root]:
"""
Expands expandable nodes in the given tree.
If force_all is false, this only expands
ExpandableEndnodes that have "always_expand" set to True.
If force_all is True, this expands ALL
ExpandableEndnodes.
"""
if not isinstance(root, lbn.Root):
raise TypeError(f"I don't know what to do with a {type(root)}")
out = root
macro_expansions = 0
it = iter(root)
for s, n in it:
if (
isinstance(n, lbn.ExpandableEndNode) and
(force_all or n.always_expand)
):
n.parent.set_side(
n.parent_side, # type: ignore
n.expand()[1]
)
it.ptr = n.parent.get_side(
n.parent_side # type: ignore
)
macro_expansions += 1
return macro_expansions, out

44
lamb_engine/nodes/misc.py Normal file
View File

@ -0,0 +1,44 @@
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 expanded a history reference
HIST_EXPAND = enum.auto()
# We turned a church numeral into an expression
AUTOCHURCH = enum.auto()
# We applied a function.
# This is the only type of "formal" reduction step.
FUNCTION_APPLY = enum.auto()
# Pretty, short names for each reduction type.
# These should all have the same length.
reduction_text = {
ReductionType.NOTHING: "N",
ReductionType.MACRO_EXPAND: "M",
ReductionType.HIST_EXPAND: "H",
ReductionType.AUTOCHURCH: "C",
ReductionType.FUNCTION_APPLY: "F",
}
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

452
lamb_engine/nodes/nodes.py Normal file
View File

@ -0,0 +1,452 @@
import lamb_engine
import lamb_engine.nodes as lbn
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.first_step = True
self.from_side = lbn.Direction.UP
def __iter__(self):
return self
def __next__(self):
# This could be implemented without checking the node type,
# but there's no reason to do that.
# Maybe later?
if self.first_step:
self.first_step = False
return self.from_side, self.ptr
if isinstance(self.ptr, Root):
if self.from_side == lbn.Direction.UP:
self.from_side, self.ptr = self.ptr.go_left()
elif isinstance(self.ptr, EndNode):
self.from_side, self.ptr = self.ptr.go_up()
elif isinstance(self.ptr, Func):
if self.from_side == lbn.Direction.UP:
self.from_side, self.ptr = self.ptr.go_left()
elif self.from_side == lbn.Direction.LEFT:
self.from_side, self.ptr = self.ptr.go_up()
elif isinstance(self.ptr, Call):
if self.from_side == lbn.Direction.UP:
self.from_side, self.ptr = self.ptr.go_left()
elif self.from_side == lbn.Direction.LEFT:
self.from_side, self.ptr = self.ptr.go_right()
elif self.from_side == lbn.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)}")
# Stop conditions
if isinstance(self.expr, Root):
if self.ptr is self.expr:
raise StopIteration
else:
if self.ptr is self.expr.parent:
raise StopIteration
return self.from_side, self.ptr
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 # type: ignore
# What direction this is relative to the parent.
# Left of Right.
self.parent_side: Direction = None # type: ignore
# Left and right nodes, None if empty
self._left = None
self._right = None
# The runner this node is attached to.
# Set by Node.set_runner()
self.runner: lamb_engine.runner.Runner = None # type: ignore
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 lbn.direction.")
if (parent is None) and (side is not None):
raise Exception("If a node has no parent, it cannot have a lbn.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, lbn.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, lbn.Direction.RIGHT)
self._right = node
def set_side(self, side: lbn.Direction, node):
"""
A wrapper around Node.left and Node.right that
automatically selects a side.
"""
if side == lbn.Direction.LEFT:
self.left = node
elif side == lbn.Direction.RIGHT:
self.right = node
else:
raise TypeError("Can only set left or right side.")
def get_side(self, side: lbn.Direction):
if side == lbn.Direction.LEFT:
return self.left
elif side == lbn.Direction.RIGHT:
return self.right
else:
raise TypeError("Can only get 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 lbn.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 lbn.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 lbn.print_node(self)
def export(self) -> str:
"""
Convert this tree to a parsable string.
"""
return lbn.print_node(self, export = True)
def set_runner(self, runner):
for s, n in self:
if s == lbn.Direction.UP:
n.runner = runner # type: ignore
return self
class EndNode(Node):
def print_value(self, *, export: bool = False) -> str:
raise NotImplementedError("EndNodes MUST provide a `print_value` method!")
class ExpandableEndNode(EndNode):
always_expand = False
def expand(self) -> tuple[lbn.ReductionType, Node]:
raise NotImplementedError("ExpandableEndNodes MUST provide an `expand` method!")
class FreeVar(EndNode):
def __init__(self, name: str, *, runner = None):
super().__init__()
self.name = name
self.runner = runner # type: ignore
def __repr__(self):
return f"<freevar {self.name}>"
def print_value(self, *, export: bool = False) -> str:
if export:
return f"{self.name}'"
else:
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, *, runner = None) -> None:
super().__init__()
self.name = name
self.left = None
self.right = None
self.runner = runner # type: ignore
def __repr__(self):
return f"<macro {self.name}>"
def print_value(self, *, export: bool = False) -> str:
return self.name
def expand(self) -> tuple[lbn.ReductionType, Node]:
if self.name in self.runner.macro_table:
# The element in the macro table will be a Root node,
# so we clone its left element.
return (
lbn.ReductionType.MACRO_EXPAND,
lbn.clone(self.runner.macro_table[self.name].left)
)
else:
raise Exception(f"Macro {self.name} is not defined")
def to_freevar(self):
return FreeVar(self.name, runner = self.runner)
def copy(self):
return Macro(self.name, runner = self.runner)
class Church(ExpandableEndNode):
@staticmethod
def from_parse(results):
return Church(int(results[0]))
def __init__(self, value: int, *, runner = None) -> None:
super().__init__()
self.value = value
self.left = None
self.right = None
self.runner = runner # type: ignore
def __repr__(self):
return f"<church {self.value}>"
def print_value(self, *, export: bool = False) -> str:
return str(self.value)
def expand(self) -> tuple[lbn.ReductionType, Node]:
f = Bound("f")
a = Bound("a")
chain = a
for i in range(self.value):
chain = Call(lbn.clone(f), lbn.clone(chain))
return (
lbn.ReductionType.AUTOCHURCH,
Func(f, Func(a, chain)).set_runner(self.runner)
)
def copy(self):
return Church(self.value, runner = self.runner)
class History(ExpandableEndNode):
always_expand = True
@staticmethod
def from_parse(results):
return History()
def __init__(self, *, runner = None) -> None:
super().__init__()
self.left = None
self.right = None
self.runner = runner # type: ignore
def __repr__(self):
return f"<$>"
def print_value(self, *, export: bool = False) -> str:
return "$"
def expand(self) -> tuple[lbn.ReductionType, Node]:
# We shouldn't ever get here, prepare()
# catches empty history.
if self.runner.history[0] == None:
raise Exception(f"Tried to expand empty history.")
# .left is VERY important!
# self.runner.history will contain Root nodes,
# and we don't want those *inside* our tree.
return lbn.ReductionType.HIST_EXPAND, lbn.clone(self.runner.history[0].left)
def copy(self):
return History(runner = self.runner)
bound_counter = 0
class Bound(EndNode):
def __init__(self, name: str, *, forced_id = None, runner = None, macro_name = None):
self.name = name
global bound_counter
self.runner = runner # type: ignore
# The name of the macro this bound came from.
# Always equal to self.name, unless the macro
# this came from had a subscript.
self.macro_name = macro_name
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,
runner = self.runner
)
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, *, export: bool = False) -> str:
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, output: Node, *, runner = None) -> None:
super().__init__()
self.input = input
self.left: Node = output
self.right: None = None
self.runner = runner # type: ignore
def __repr__(self):
return f"<func {self.input!r} {self.left!r}>"
def copy(self):
return Func(
Bound(
self.input.name,
runner = self.runner
),
None, # type: ignore
runner = self.runner
)
class Root(Node):
"""
Root node.
Used at the top of an expression.
"""
def __init__(self, left: Node, *, runner = None) -> None:
super().__init__()
self.left: Node = left
self.runner = runner # type: ignore
def __repr__(self):
return f"<Root {self.left!r}>"
def copy(self):
return Root(None, runner = self.runner) # 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, *, runner = None) -> None:
super().__init__()
self.left: Node = fn
self.right: Node = arg
self.runner = runner # type: ignore
def __repr__(self):
return f"<call {self.left!r} {self.right!r}>"
def copy(self):
return Call(None, None, runner = self.runner) # type: ignore

View File

@ -13,16 +13,17 @@ class LambdaParser:
# We still create macro objects from them, they are turned into
# bound variables after the expression is built.
self.pp_macro = pp.Word(pp.alphas + "_")
self.pp_bound = pp.Char(pp.srange("[a-z]"))
self.pp_bound = pp.Regex("[a-z][₀₁₂₃₄₅₆₈₉]*")
self.pp_name = self.pp_bound ^ self.pp_macro
self.pp_church = pp.Word(pp.nums)
self.pp_history = pp.Char("$")
# Function calls.
#
# <exp> <exp>
# <exp> <exp> <exp>
self.pp_call = pp.Forward()
self.pp_call <<= (self.pp_expr | self.pp_bound)[2, ...]
self.pp_call <<= (self.pp_expr | self.pp_bound | self.pp_history)[2, ...]
# Function definitions, right associative.
# Function args MUST be lowercase.
@ -43,7 +44,7 @@ class LambdaParser:
pp.line_start() +
self.pp_macro +
pp.Suppress("=") +
(self.pp_expr ^ self.pp_call)
(self.pp_expr ^ self.pp_call ^ self.pp_history)
)
self.pp_expr <<= (
@ -51,17 +52,19 @@ class LambdaParser:
self.pp_lambda_fun ^
self.pp_name ^
(self.lp + self.pp_expr + self.rp) ^
(self.lp + self.pp_call + self.rp)
(self.lp + self.pp_call + self.rp) ^
(self.lp + self.pp_history + self.rp)
)
self.pp_command = pp.Suppress(":") + pp.Word(pp.alphas + "_") + pp.Word(pp.alphas + pp.nums + "_")[0, ...]
self.pp_command = pp.Suppress(":") + pp.Word(pp.alphas + "_") + pp.Word(pp.printables)[0, ...]
self.pp_all = (
self.pp_expr ^
self.pp_macro_def ^
self.pp_command ^
self.pp_call
self.pp_call ^
self.pp_history
)
def __init__(
@ -73,7 +76,8 @@ class LambdaParser:
action_func,
action_bound,
action_macro,
action_call
action_call,
action_history
):
self.make_parser()
@ -85,6 +89,7 @@ class LambdaParser:
self.pp_macro.set_parse_action(action_macro)
self.pp_bound.set_parse_action(action_bound)
self.pp_call.set_parse_action(action_call)
self.pp_history.set_parse_action(action_history)
def parse_line(self, line: str):
return self.pp_all.parse_string(

View File

@ -0,0 +1,2 @@
from .runner import Runner
from .runner import StopReason

View File

@ -0,0 +1,420 @@
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit import print_formatted_text as printf
from prompt_toolkit.shortcuts import clear as clear_screen
from prompt_toolkit import prompt
import os.path
from pyparsing import exceptions as ppx
import lamb_engine
commands = {}
help_texts = {}
def lamb_command(
*,
command_name = None,
help_text: str
):
"""
A decorator that allows us to easily make commands
"""
def inner(func):
name = func.__name__ if command_name is None else command_name
commands[name] = func
help_texts[name] = help_text
return inner
@lamb_command(
command_name = "step",
help_text = "Toggle step-by-step reduction"
)
def cmd_step(command, runner) -> None:
if len(command.args) > 1:
printf(
HTML(
f"<err>Command <code>:{command.name}</code> takes no more than one argument.</err>"
),
style = lamb_engine.utils.style
)
return
target = not runner.step_reduction
if len(command.args) == 1:
if command.args[0].lower() in ("y", "yes"):
target = True
elif command.args[0].lower() in ("n", "no"):
target = False
else:
printf(
HTML(
f"<err>Usage: <code>:step [yes|no]</code></err>"
),
style = lamb_engine.utils.style
)
return
if target:
printf(
HTML(
f"<warn>Enabled step-by-step reduction.</warn>"
),
style = lamb_engine.utils.style
)
runner.step_reduction = True
else:
printf(
HTML(
f"<warn>Disabled step-by-step reduction.</warn>"
),
style = lamb_engine.utils.style
)
runner.step_reduction = False
@lamb_command(
command_name = "expand",
help_text = "Toggle full expansion"
)
def cmd_expand(command, runner) -> None:
if len(command.args) > 1:
printf(
HTML(
f"<err>Command <code>:{command.name}</code> takes no more than one argument.</err>"
),
style = lamb_engine.utils.style
)
return
target = not runner.full_expansion
if len(command.args) == 1:
if command.args[0].lower() in ("y", "yes"):
target = True
elif command.args[0].lower() in ("n", "no"):
target = False
else:
printf(
HTML(
f"<err>Usage: <code>:expand [yes|no]</code></err>"
),
style = lamb_engine.utils.style
)
return
if target:
printf(
HTML(
f"<warn>Enabled complete expansion.</warn>"
),
style = lamb_engine.utils.style
)
runner.full_expansion = True
else:
printf(
HTML(
f"<warn>Disabled complete expansion.</warn>"
),
style = lamb_engine.utils.style
)
runner.full_expansion = False
@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(
f"<err>Command <code>:{command.name}</code> takes exactly one argument.</err>"
),
style = lamb_engine.utils.style
)
return
target = command.args[0]
if os.path.exists(target):
confirm = prompt(
message = FormattedText([
("class:warn", "File exists. Overwrite? "),
("class:text", "[yes/no]: ")
]),
style = lamb_engine.utils.style
).lower()
if confirm != "yes":
printf(
HTML(
"<err>Cancelled.</err>"
),
style = lamb_engine.utils.style
)
return
with open(target, "w") as f:
f.write("\n".join(
[f"{n} = {e.export()}" for n, e in runner.macro_table.items()]
))
printf(
HTML(
f"Wrote {len(runner.macro_table)} macros to <code>{target}</code>"
),
style = lamb_engine.utils.style
)
@lamb_command(
command_name = "load",
help_text = "Load macros from a file"
)
def cmd_load(command, runner):
if len(command.args) != 1:
printf(
HTML(
f"<err>Command <code>:{command.name}</code> takes exactly one argument.</err>"
),
style = lamb_engine.utils.style
)
return
target = command.args[0]
if not os.path.exists(target):
printf(
HTML(
f"<err>File {target} doesn't exist.</err>"
),
style = lamb_engine.utils.style
)
return
with open(target, "r") as f:
lines = [x.strip() for x in f.readlines()]
for i in range(len(lines)):
l = lines[i].strip()
# Skip comments and empty lines
if l.startswith("#"):
continue
if l == "":
continue
try:
x = runner.parse(l)[0]
except ppx.ParseException as e:
printf(
FormattedText([
("class:warn", f"Syntax error on line {i+1:02}: "),
("class:code", l[:e.loc]),
("class:err", l[e.loc]),
("class:code", l[e.loc+1:])
]),
style = lamb_engine.utils.style
)
return
if not isinstance(x, lamb_engine.runner.runner.MacroDef):
printf(
FormattedText([
("class:warn", f"Skipping line {i+1:02}: "),
("class:code", l),
("class:warn", f" is not a macro definition.")
]),
style = lamb_engine.utils.style
)
return
runner.save_macro(x, silent = True)
printf(
FormattedText([
("class:ok", f"Loaded {x.label}: ")
] + lamb_engine.utils.lex_str(str(x.expr))),
style = lamb_engine.utils.style
)
@lamb_command(
help_text = "Delete a macro"
)
def mdel(command, runner) -> None:
if len(command.args) != 1:
printf(
HTML(
f"<err>Command <code>:{command.name}</code> takes exactly one argument.</err>"
),
style = lamb_engine.utils.style
)
return
target = command.args[0]
if target not in runner.macro_table:
printf(
HTML(
f"<warn>Macro \"{target}\" is not defined</warn>"
),
style = lamb_engine.utils.style
)
return
del runner.macro_table[target]
@lamb_command(
help_text = "Delete all macros"
)
def delmac(command, runner) -> None:
confirm = prompt(
message = FormattedText([
("class:warn", "Are you sure? "),
("class:text", "[yes/no]: ")
]),
style = lamb_engine.utils.style
).lower()
if confirm != "yes":
printf(
HTML(
"<err>Cancelled.</err>"
),
style = lamb_engine.utils.style
)
return
runner.macro_table = {}
@lamb_command(
help_text = "Show macros"
)
def macros(command, runner) -> None:
if len(runner.macro_table) == 0:
printf(FormattedText([
("class:warn", "No macros are defined."),
]),
style = lamb_engine.utils.style
)
else:
printf(FormattedText([
("class:cmd_h", "\nDefined Macros:\n"),
] +
[
("class:text", f"\t{name} \t {exp}\n")
for name, exp in runner.macro_table.items()
]),
style = lamb_engine.utils.style
)
@lamb_command(
help_text = "Clear the screen"
)
def clear(command, runner) -> None:
clear_screen()
lamb_engine.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_engine.utils.style
)
else:
printf(
HTML(
f"<ok>Reduction limit is {runner.reduction_limit:,}</ok>"
),
style = lamb_engine.utils.style
)
return
elif len(command.args) != 1:
printf(
HTML(
f"<err>Command <code>:{command.name}</code> takes exactly one argument.</err>"
),
style = lamb_engine.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_engine.utils.style
)
return
try:
t = int(t)
except ValueError:
printf(
HTML(
"<err>Reduction limit must be a positive integer or \"none\".</err>"
),
style = lamb_engine.utils.style
)
return
if 50 > t:
printf(
HTML(
"<err>Reduction limit must be at least 50.</err>"
),
style = lamb_engine.utils.style
)
return
runner.reduction_limit = t
printf(
HTML(
f"<ok>Set reduction limit to {t:,}</ok>"
),
style = lamb_engine.utils.style
)
@lamb_command(
help_text = "Print this help"
)
def help(command, runner) -> None:
printf(
HTML(
"\n<text>" +
"<cmd_h>Usage:</cmd_h>" +
"\n" +
"\tWrite lambda expressions using your <cmd_key>\\</cmd_key> key." +
"\n" +
"\tMacros can be defined using <cmd_key>=</cmd_key>, as in <code>T = λab.a</code>" +
"\n" +
"\tRun commands using <cmd_key>:</cmd_key>, for example <code>:help</code>" +
"\n" +
"\tHistory can be accessed with <cmd_key>$</cmd_key>, which will expand to the result of the last successful reduction." +
"\n\n" +
"<cmd_h>Commands:</cmd_h>"+
"\n" +
"\n".join([
f"\t<code>{name}</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>" +
"</text>"
),
style = lamb_engine.utils.style
)

View File

@ -0,0 +1,42 @@
import enum
import lamb_engine
class StopReason(enum.Enum):
BETA_NORMAL = ("class:text", "β-normal form")
LOOP_DETECTED = ("class:warn", "Loop detected")
MAX_EXCEEDED = ("class:err", "Too many reductions")
INTERRUPT = ("class:warn", "User interrupt")
SHOW_MACRO = ("class:text", "Displaying macro content")
class MacroDef:
@staticmethod
def from_parse(result):
return MacroDef(
result[0].name,
result[1]
)
def __init__(self, label: str, expr: lamb_engine.nodes.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 set_runner(self, runner):
return self.expr.set_runner(runner)
class Command:
@staticmethod
def from_parse(result):
return Command(
result[0],
result[1:]
)
def __init__(self, name, args):
self.name = name
self.args = args

View File

@ -0,0 +1,303 @@
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit import prompt
from prompt_toolkit import print_formatted_text as printf
import collections
import math
import time
import lamb_engine
from lamb_engine.runner.misc import MacroDef
from lamb_engine.runner.misc import Command
from lamb_engine.runner.misc import StopReason
from lamb_engine.runner import commands as cmd
# Keybindings for step prompt.
# Prevents any text from being input.
step_bindings = KeyBindings()
@step_bindings.add("<any>")
def _(event):
pass
class Runner:
def __init__(
self,
prompt_session: PromptSession,
prompt_message
):
self.macro_table = {}
self.prompt_session = prompt_session
self.prompt_message = prompt_message
self.parser = lamb_engine.parser.LambdaParser(
action_func = lamb_engine.nodes.Func.from_parse,
action_bound = lamb_engine.nodes.Macro.from_parse,
action_macro = lamb_engine.nodes.Macro.from_parse,
action_call = lamb_engine.nodes.Call.from_parse,
action_church = lamb_engine.nodes.Church.from_parse,
action_macro_def = MacroDef.from_parse,
action_command = Command.from_parse,
action_history = lamb_engine.nodes.History.from_parse
)
# Maximum amount of reductions.
# If None, no maximum is enforced.
# Must be at least 1.
self.reduction_limit = 1_000_000
# Ensure bound variables are unique.
# This is automatically incremented whenever we make
# 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
self.history = collections.deque(
[None] * 10,
10)
# If true, reduce step-by-step.
self.step_reduction = False
# If true, expand ALL macros when printing output
self.full_expansion = False
def prompt(self):
return self.prompt_session.prompt(
message = self.prompt_message
)
def parse(self, line): # -> tuple[lamb_engine.nodes.Root | MacroDef | Command, list]
e = self.parser.parse_line(line)
w = []
if isinstance(e, MacroDef):
e.expr = lamb_engine.nodes.Root(e.expr)
e.set_runner(self)
w = lamb_engine.nodes.prepare(e.expr, ban_macro_name = e.label)
elif isinstance(e, lamb_engine.nodes.Node):
e = lamb_engine.nodes.Root(e)
e.set_runner(self)
w = lamb_engine.nodes.prepare(e)
return e, w
def reduce(self, node: lamb_engine.nodes.Root, *, warnings = []) -> None:
# Reduction Counter.
# We also count macro (and church) expansions,
# and subtract those from the final count.
k = 0
macro_expansions = 0
stop_reason = StopReason.MAX_EXCEEDED
start_time = time.time()
out_text = []
only_macro = (
isinstance(node.left, lamb_engine.nodes.Macro) or
isinstance(node.left, lamb_engine.nodes.Church)
)
if only_macro:
stop_reason = StopReason.SHOW_MACRO
m, node = lamb_engine.nodes.expand(node, force_all = only_macro)
macro_expansions += m
if len(warnings) != 0:
printf(FormattedText(warnings), style = lamb_engine.utils.style)
if self.step_reduction:
printf(FormattedText([
("class:warn", "Step-by-step reduction is enabled.\n"),
("class:muted", "Press "),
("class:cmd_key", "ctrl-c"),
("class:muted", " to continue automatically.\n"),
("class:muted", "Press "),
("class:cmd_key", "enter"),
("class:muted", " to step.\n"),
]), style = lamb_engine.utils.style)
skip_to_end = False
try:
while (
(
(self.reduction_limit is None) or
(k < self.reduction_limit)
) and not only_macro
):
# Show reduction count
if (
( (k >= self.iter_update) and (k % self.iter_update == 0) )
and not (self.step_reduction and not skip_to_end)
):
print(f" Reducing... {k:,}", end = "\r")
# Reduce
red_type, node = lamb_engine.nodes.reduce(node)
# If we can't reduce this expression anymore,
# it's in beta-normal form.
if red_type == lamb_engine.nodes.ReductionType.NOTHING:
stop_reason = StopReason.BETA_NORMAL
break
# Count reductions
k += 1
if red_type == lamb_engine.nodes.ReductionType.FUNCTION_APPLY:
macro_expansions += 1
# Pause after step if necessary
if self.step_reduction and not skip_to_end:
try:
s = prompt(
message = FormattedText([
("class:prompt", lamb_engine.nodes.reduction_text[red_type]),
("class:prompt", f":{k:03} ")
] + lamb_engine.utils.lex_str(str(node))),
style = lamb_engine.utils.style,
key_bindings = step_bindings
)
except KeyboardInterrupt or EOFError:
skip_to_end = True
printf(FormattedText([
("class:warn", "Skipping to end."),
]), style = lamb_engine.utils.style)
# Gracefully catch keyboard interrupts
except KeyboardInterrupt:
stop_reason = StopReason.INTERRUPT
# Print a space between step messages
if self.step_reduction:
print("")
# Clear reduction counter if it was printed
if k >= self.iter_update:
print(" " * round(14 + math.log10(k)), end = "\r")
# Expand fully if necessary
if self.full_expansion:
o, node = lamb_engine.nodes.expand(node, force_all = True)
macro_expansions += o
if only_macro:
out_text += [
("class:ok", f"Displaying macro content")
]
else:
if not self.step_reduction:
out_text += [
("class:ok", f"Runtime: "),
("class:text", f"{time.time() - start_time:.03f} seconds"),
("class:text", "\n")
]
out_text += [
("class:ok", f"Exit reason: "),
stop_reason.value,
("class:text", "\n"),
("class:ok", f"Macro expansions: "),
("class:text", f"{macro_expansions:,}"),
("class:text", "\n"),
("class:ok", f"Reductions: "),
("class:text", f"{k:,}\t"),
("class:muted", f"(Limit: {self.reduction_limit:,})")
]
if self.full_expansion:
out_text += [
("class:text", "\n"),
("class:ok", "All macros have been expanded")
]
if (
stop_reason == StopReason.BETA_NORMAL or
stop_reason == StopReason.LOOP_DETECTED or
only_macro
):
out_text += [
("class:ok", "\n\n => ")
] + lamb_engine.utils.lex_str(str(node))
printf(
FormattedText(out_text),
style = lamb_engine.utils.style
)
# Save to history
# Do this at the end so we don't always fully expand.
self.history.appendleft(
lamb_engine.nodes.expand( # type: ignore
node,
force_all = True
)[1]
)
def save_macro(
self,
macro: MacroDef,
*,
silent = False
) -> None:
was_rewritten = macro.label in self.macro_table
self.macro_table[macro.label] = macro.expr
if not silent:
printf(FormattedText([
("class:text", "Set "),
("class:code", macro.label),
("class:text", " to "),
("class:code", str(macro.expr))
]), style = lamb_engine.utils.style)
# Apply a list of definitions
def run(
self,
line: str,
*,
silent = False
) -> None:
e, w = self.parse(line)
# If this line is a macro definition, save the macro.
if isinstance(e, MacroDef):
self.save_macro(e, silent = silent)
# If this line is a command, do the command.
elif isinstance(e, Command):
if e.name not in cmd.commands:
printf(
FormattedText([
("class:warn", f"Unknown command \"{e.name}\"")
]),
style = lamb_engine.utils.style
)
else:
cmd.commands[e.name](e, self)
# If this line is a plain expression, reduce it.
elif isinstance(e, lamb_engine.nodes.Node):
self.reduce(e, warnings = w)
# We shouldn't ever get here.
else:
raise TypeError(f"I don't know what to do with a {type(e)}")
def run_lines(self, lines: list[str]):
for l in lines:
self.run(l, silent = True)

165
lamb_engine/utils.py Normal file
View File

@ -0,0 +1,165 @@
from prompt_toolkit.styles import Style
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.lexers import Lexer
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit import print_formatted_text as printf
from importlib.metadata import version
from prompt_toolkit.document import Document
import re
style = Style.from_dict({ # type: ignore
# Basic formatting
"text": "#FFFFFF",
"warn": "#FFA700",
"err": "#FF3809",
"prompt": "#05CFFF",
"ok": "#00EF7C",
"code": "#AAAAAA italic",
"muted": "#AAAAAA",
# Syntax highlighting colors
"syn_cmd": "#FFFFFF italic",
"syn_lambda": "#AAAAAA",
"syn_paren": "#AAAAAA",
# Command formatting
# cmd_h: section titles
# cmd_key: keyboard keys, usually one character
"cmd_h": "#FF3809 bold",
"cmd_key": "#00EF7C bold",
# Only used in greeting
"_v": "#00EF7C bold",
"_l": "#FF3809 bold",
"_s": "#00EF7C bold",
"_p": "#AAAAAA"
})
# Replace "\" with pretty "λ"s
bindings = KeyBindings()
@bindings.add("\\")
def _(event):
event.current_buffer.insert_text("λ")
# Simple lexer for highlighting.
# Improve this later.
class LambdaLexer(Lexer):
def lex_document(self, document):
def inner(line_no):
out = []
tmp_str = []
d = str(document.lines[line_no])
if d.startswith(":"):
return [
("class:syn_cmd", d)
]
for c in d:
if c in "\\λ.":
if len(tmp_str) != 0:
out.append(("class:text", "".join(tmp_str)))
out.append(("class:syn_lambda", c))
tmp_str = []
elif c in "()":
if len(tmp_str) != 0:
out.append(("class:text", "".join(tmp_str)))
out.append(("class:syn_paren", c))
tmp_str = []
else:
tmp_str.append(c)
if len(tmp_str) != 0:
out.append(("class:text", "".join(tmp_str)))
return out
return inner
def lex_str(s: str) -> list[tuple[str, str]]:
return LambdaLexer().lex_document(Document(s))(0)
def show_greeting():
# | _.._ _.|_
# |_(_|| | ||_)
# 0.0.0
#
# __ __
# ,-` `` `,
# (` \ )
# (` \ `)
# (, / \ _)
# (` / \ )
# `'._.--._.'
#
# A λ calculus engine
printf(HTML("\n".join([
"",
"<_h> | _.._ _.|_",
" |_(_|| | ||_)</_h>",
f" <_v>{version('lamb_engine')}</_v>",
" __ __",
" ,-` `` `,",
" (` <_l>\\</_l> )",
" (` <_l>\\</_l> `)",
" (, <_l>/ \\</_l> _)",
" (` <_l>/ \\</_l> )",
" `'._.--._.'",
"",
"<_s> A λ calculus engine</_s>",
"<_p> Type :help for help</_p>",
""
])), style = style)
def remove_sub(s: str):
return re.sub("[₀₁₂₃₄₅₆₈₉]*", "", s)
def base4(n: int):
if n == 0:
return [0]
digits = []
while n:
digits.append(n % 4)
n //= 4
return digits[::-1]
def subscript(num: int):
# unicode subscripts ₀₁₂₃ and ₄₅₆₈₉
# usually look different,
# so we'll use base 4.
qb = base4(num)
sub = {
"0": "",
"1": "",
"2": "",
"3": "",
"4": "",
"5": "",
"6": "",
"7": "",
"8": "",
"9": ""
}
sup = {
"0": "",
"1": "¹",
"2": "²",
"3": "³",
"4": "",
"5": "",
"6": "",
"7": "",
"8": "",
"9": ""
}
return "".join(
[sub[str(x)] for x in qb]
)

77
macros.lamb Normal file
View File

@ -0,0 +1,77 @@
# How to use exported files in lamb:
#
# [Syntax Highlighting]
# Most languages' syntax highlighters will
# highlight this code well. Set it manually
# in your editor.
#
# Don't use a language for which you have a
# linter installed, you'll get lots of errors.
#
# Choose a language you don't have extenstions for,
# and a language that uses # comments.
#
# The following worked well in vscode:
# - Julia
# - Perl
# - Coffeescript
# - R
# [Writing macros]
# If you don't have a custom keyboard layout that can
# create λs, you may use backslashes instead.
# (As in `T = \ab.b`)
#
# This file must only contain macro definitons. Commands will be ignored.
# Statements CANNOT be split among multiple lines.
# Comments CANNOT be on the same line as macro defintions.
# All leading whitespace is ignored.
# Misc Combinators
M = λx.(x x)
W = (M M)
Y = λf.( (λx.(f (x x))) (λx.(f (x x))) )
# Booleans
T = λab.a
F = λab.b
NOT = λa.(a F T)
AND = λab.(a b F)
OR = λab.(a T b)
XOR = λab.((a (NOT b)) b)
# Numbers
# PAIR: prerequisite for H.
# Makes a two-value tuple, indexed with T and F.
#
# H: shift-and-add, prerequisite for D
#
# S: successor (adds 1)
#
# D: predecessor (subtracts 1)
#
# Z: tests if a number is zero
# NZ: equivalent to `NOT Z`
#
# ADD: adds two numbers
#
# MULT: multiply two numbers
#
# FAC:
# Recursive factorial. Call with `Y FAC <number>`
# Don't call this with numbers bigger than 5 unless you're very patient.
#
# `Y FAC 6` required 867,920 reductions and took 10 minutes to run.
PAIR = λabi.(i a b)
S = λnfa.(f (n f a))
H = λp.((PAIR (p F)) (S (p F)))
D = λn.(n H (PAIR 0 0) T)
Z = λn.(n (λa.F) T)
NZ = λn.(n (λa.T) F)
ADD = λmn.(m S n)
MULT = λnmf.(n (m f))
FAC = λyn.(Z n) (1) (MULT n (y (D n)))

84
misc/demo.tape Normal file
View File

@ -0,0 +1,84 @@
# See makedemo.sh
#Output lambdemo.mp4
Output lambdemo.gif
Set FontSize 30
Set Width 2000
Set Height 1500
Set FontFamily "FantasqueSansMono NF"
Set TypingSpeed 60ms
Set Framerate 30
# Intro
Sleep 2000ms
Type "lamb"
Sleep 1000ms
Enter
Sleep 2000ms
# Demo 1: load
Type ":load ../macros.lamb"
Sleep 500ms
Enter
Sleep 2000ms
Type "NOT T"
Sleep 1000ms
Enter
Sleep 6s
Type ":clear"
Sleep 1000ms
Enter
Sleep 1500ms
# Demo 2: stepping
Type ":step"
Sleep 500ms
Enter
Sleep 1500ms
Type "NOT T"
Sleep 100ms
Enter
Sleep 1500ms
Enter
Sleep 760ms
Enter
Sleep 850ms
Enter
Sleep 650ms
Enter
Sleep 700ms
Enter
Sleep 3000ms
Type ":step"
Sleep 500ms
Enter
Sleep 6s
Type ":clear"
Sleep 1000ms
Enter
Sleep 1500ms
# Demo 3: macros
Type "M = \x.x x"
Sleep 500ms
Enter
Sleep 500ms
Type "M M"
Sleep 500ms
Enter
Sleep 3s
Ctrl+c
Sleep 1000ms
Type "Y FAC 3"
Sleep 500ms
Enter
Sleep 6s

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 30 KiB

34
misc/makedemo.sh Executable file
View File

@ -0,0 +1,34 @@
#!/bin/bash
# Should be run from the misc directory.
# Will not work with any other root.
# Create this file.
# Should define two variables:
# DAV_USER="name:password"
# DAV_URL="https://site.com/dav-path"
if [[ -f "secrets.sh" ]]; then
source secrets.sh
else
echo "Cannot run without secrets.sh"
exit
fi
# Activate venv if not in venv
if [[ "$VIRTUAL_ENV" == "" ]]; then
source ../venv/bin/activate
fi
# Make sure our venv is running the latest
# version of lamb.
pip install --editable ..
# Make gif
vhs < demo.tape
# Upload
curl \
--user $DAV_USER \
--url $DAV_URL \
--upload-file "lambdemo.gif"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

View File

@ -1,30 +1,28 @@
[build-system]
requires = [ "setuptools>=61.0" ]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = [ "." ]
include = ["lamb_engine*"]
namespaces = false
[project.scripts]
lamb = "lamb_engine:main"
[project]
name = "Lamb"
name = "lamb_engine"
description = "A lambda calculus engine"
# 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.1"
version = "1.1.9"
dependencies = [
"prompt-toolkit==3.0.31",
"pyparsing==3.0.9"
]
authors = [
{ name="Mark", email="mark@betalupi.com" }
]
readme = "README.md"
requires-python = ">=3.7"
license = {text = "GNU General Public License v3 (GPLv3)"}
@ -35,20 +33,10 @@ classifiers = [
"Environment :: Console"
]
[project.urls]
"Homepage" = "https://git.betalupi.com/Mark/lamb"
[build-system]
requires = [ "setuptools>=61.0" ]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["lamb"]
include = ["lamb*"]
namespaces = false
[project.optional-dependencies]
# Used to build a standalone executable
pyinstaller = [ "pyinstaller==5.5" ]
# To build:
# pip install build twine
# python -m build
# twine upload dist/lamb_engine-1.1.6* (change the version)