diff --git a/README.md b/README.md index f60a6f9..ecafee1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ ## Todo (pre-release): - - Good command parsing (`:save`, `:load`, are a bare minimum) - $\alpha$-equivalence check - Prettyprint functions (combine args, rename bound variables) - Write a nice README @@ -18,6 +17,7 @@ - Syntax highlighting: parenthesis, bound variables, macros, etc - Pin header to top of screen - PyPi package + - Smart alignment in all printouts ## Mention in Docs - lambda functions only work with single-letter arguments diff --git a/lamb/__main__.py b/lamb/__main__.py index 7f615624..7c12200 100755 --- a/lamb/__main__.py +++ b/lamb/__main__.py @@ -8,7 +8,6 @@ from prompt_toolkit.lexers import Lexer from pyparsing import exceptions as ppx -from lamb.parser import Parser import lamb.runner as runner import lamb.runstatus as rs import lamb.tokens as tokens @@ -23,27 +22,29 @@ class LambdaLexer(Lexer): return [("class:text", str(document.lines[line_no]))] return inner -# Replace "\" with a pretty "λ" in the prompt + +utils.show_greeting() + + +# Replace "\" with pretty "λ"s bindings = KeyBindings() @bindings.add("\\") def _(event): event.current_buffer.insert_text("λ") -session = PromptSession( - message = FormattedText([ + +r = runner.Runner( + prompt_session = PromptSession( + style = utils.style, + lexer = LambdaLexer(), + key_bindings = bindings + ), + + prompt_message = FormattedText([ ("class:prompt", "~~> ") ]), - style = utils.style, - lexer = LambdaLexer(), - key_bindings = bindings ) - -utils.show_greeting() - - -r = runner.Runner() - r.run_lines([ "T = λab.a", "F = λab.b", @@ -63,7 +64,7 @@ r.run_lines([ while True: try: - i = session.prompt() + i = r.prompt() # Catch Ctrl-C and Ctrl-D except KeyboardInterrupt: @@ -83,7 +84,7 @@ while True: try: x = r.run(i) except ppx.ParseException as e: - l = len(to_plain_text(session.message)) + 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}."), @@ -107,7 +108,7 @@ while True: if isinstance(x, rs.CommandStatus): - printf(x.formatted_text, style = utils.style) + pass # If this line was an expression, print reduction status elif isinstance(x, rs.ReduceStatus): diff --git a/lamb/commands.py b/lamb/commands.py index fe53aaa..8566d2a 100644 --- a/lamb/commands.py +++ b/lamb/commands.py @@ -1,8 +1,12 @@ 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 lamb.runstatus import CommandStatus +import os.path + +from pyparsing import exceptions as ppx +import lamb.runstatus as rs import lamb.utils as utils @@ -16,60 +20,165 @@ def lamb_command(*, help_text: str): help_texts[func.__name__] = help_text return inner -def run(command, runner): +def run(command, runner) -> None: if command.name not in commands: - return CommandStatus( - formatted_text = FormattedText([ + printf( + FormattedText([ ("class:warn", f"Unknown command \"{command.name}\"") - ]) + ]), + style = utils.style ) else: - return commands[command.name](command, runner) + commands[command.name](command, runner) + + +@lamb_command(help_text = "Save macros to a file") +def save(command, runner) -> None: + if len(command.args) != 1: + printf( + HTML( + "Command :save takes exactly one argument." + ), + style = 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( + "Cancelled." + ), + style = utils.style + ) + return + + with open(target, "w") as f: + f.write("\n".join( + [f"{n} = {e}" for n, e in runner.macro_table.items()] + )) + + printf( + HTML( + f"Wrote {len(runner.macro_table)} macros to {target}" + ), + style = utils.style + ) + + +@lamb_command(help_text = "Load macros from a file") +def load(command, runner): + if len(command.args) != 1: + printf( + HTML( + "Command :load takes exactly one argument." + ), + style = utils.style + ) + return + + target = command.args[0] + if not os.path.exists(target): + printf( + HTML( + f"File {target} doesn't exist." + ), + style = 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.run(l, macro_only = True) + 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 = utils.style + ) + except rs.NotAMacro: + printf( + FormattedText([ + ("class:warn", f"Skipping line {i+1:02}: "), + ("class:cmd_code", l), + ("class:warn", f" is not a macro definition.") + ]), + style = utils.style + ) + else: + printf( + FormattedText([ + ("class:ok", f"Loaded {x.macro_label}: "), + ("class:cmd_code", str(x.macro_expr)) + ]), + style = utils.style + ) + + @lamb_command(help_text = "Delete a macro") -def mdel(command, runner): +def mdel(command, runner) -> None: if len(command.args) != 1: - return CommandStatus( - formatted_text = HTML( - "Command :mdel takes exactly one argument." - ) + printf( + HTML( + "Command :mdel takes exactly one argument." + ), + style = utils.style ) + return target = command.args[0] if target not in runner.macro_table: - return CommandStatus( - formatted_text = HTML( + printf( + HTML( f"Macro \"{target}\" is not defined" - ) + ), + style = utils.style ) + return del runner.macro_table[target] -@lamb_command(help_text = "Show macros") -def macros(command, runner): - return CommandStatus( - # Can't use HTML here, certain characters might break it. - formatted_text = FormattedText([ + +@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 = utils.style ) @lamb_command(help_text = "Clear the screen") -def clear(command, runner): +def clear(command, runner) -> None: clear_screen() utils.show_greeting() @lamb_command(help_text = "Print this help") -def help(command, runner): - return CommandStatus( - formatted_text = HTML( +def help(command, runner) -> None: + printf( + HTML( "\n" + "Usage:" + "\n" + @@ -86,5 +195,6 @@ def help(command, runner): for name, text in help_texts.items() ]) + "" - ) + ), + style = utils.style ) \ No newline at end of file diff --git a/lamb/runner.py b/lamb/runner.py index bcc8e20..0da75b7 100644 --- a/lamb/runner.py +++ b/lamb/runner.py @@ -1,5 +1,4 @@ -from distutils.cmd import Command -from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit import PromptSession import lamb.commands as commands from lamb.parser import Parser @@ -7,15 +6,19 @@ import lamb.tokens as tokens import lamb.runstatus as rs - class Runner: - def __init__(self): + def __init__(self, prompt_session: PromptSession, prompt_message): self.macro_table = {} + self.prompt_session = prompt_session + self.prompt_message = prompt_message # Maximum amount of reductions. # If None, no maximum is enforced. self.reduction_limit: int | None = 300 + def prompt(self): + return self.prompt_session.prompt(message = self.prompt_message) + def reduce_expression(self, expr: tokens.LambdaToken) -> rs.ReduceStatus: @@ -51,7 +54,7 @@ class Runner: # Apply a list of definitions - def run(self, line: str) -> rs.RunStatus: + def run(self, line: str, *, macro_only = False) -> rs.RunStatus: e = Parser.parse_line(line) # If this line is a macro definition, save the macro. @@ -67,14 +70,20 @@ class Runner: macro_expr = e.exp ) + elif macro_only: + raise rs.NotAMacro() + # If this line is a command, do the command. elif isinstance(e, tokens.command): - return commands.run(e, self) + commands.run(e, self) + return rs.CommandStatus(cmd = e.name) # If this line is a plain expression, reduce it. elif isinstance(e, tokens.LambdaToken): e.bind_variables() return self.reduce_expression(e) + + # We shouldn't ever get here. else: raise TypeError(f"I don't know what to do with a {type(e)}") diff --git a/lamb/runstatus.py b/lamb/runstatus.py index 434703c..f307e6c 100644 --- a/lamb/runstatus.py +++ b/lamb/runstatus.py @@ -4,6 +4,16 @@ import enum import lamb.tokens as tokens + +class NotAMacro(Exception): + """ + Raised when we try to run a non-macro line + while enforcing macro_only in Runner.run(). + + This should be caught and elegantly presented to the user. + """ + pass + class RunStatus: """ Base class for run status. @@ -64,14 +74,11 @@ class ReduceStatus(RunStatus): class CommandStatus(RunStatus): """ Returned when a command is executed. + Doesn't do anything interesting. Values: - `formatted_text`: What to print after this command is executed + `cmd`: The command that was run, without a colon. """ - def __init__( - self, - *, - formatted_text: FormattedText | HTML - ): - self.formatted_text = formatted_text \ No newline at end of file + def __init__(self, *, cmd: str): + self.cmd = cmd \ No newline at end of file diff --git a/lamb/utils.py b/lamb/utils.py index fb55bb3..23fb1b9 100644 --- a/lamb/utils.py +++ b/lamb/utils.py @@ -36,6 +36,7 @@ style = Style.from_dict({ "warn": "#FFFF00", "err": "#FF0000", "prompt": "#00FFFF", + "ok": "#B4EC85", # Syntax "syn_macro": "#FF00FF",