diff --git a/Dockerfile b/Dockerfile index 90df097d..b44e9f5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN bin/ape.elf bin/python -m compileall Lib RUN mv Lib/__pycache__/scrapscript*.pyc Lib/scrapscript.pyc RUN rm Lib/scrapscript.py RUN cp bin/python bin/scrapscript.com +COPY style.css Lib +COPY repl.html Lib RUN sh bin/zip -A -r bin/scrapscript.com Lib .args RUN bin/ape.elf bin/assimilate bin/scrapscript.com diff --git a/build-com b/build-com index 064b4547..be879fd5 100755 --- a/build-com +++ b/build-com @@ -4,6 +4,8 @@ PREV="$(pwd)" DIR="$(mktemp -d)" cp scrapscript.py "$DIR" cp .args "$DIR" +cp repl.html "$DIR" +cp style.css "$DIR" cd "$DIR" wget https://cosmo.zip/pub/cosmos/bin/python wget https://cosmo.zip/pub/cosmos/bin/zip @@ -11,7 +13,8 @@ chmod +x python chmod +x zip ./python -m compileall scrapscript.py mkdir Lib -cp __pycache__/scrapscript.cpython-311.pyc Lib/scrapscript.pyc +cp __pycache__/scrapscript.*.pyc Lib/scrapscript.pyc +cp style.css repl.html Lib cp python scrapscript.com ./zip -r scrapscript.com Lib .args echo "Testing..." diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..0943ae52 --- /dev/null +++ b/fly.toml @@ -0,0 +1,25 @@ +# fly.toml app configuration file generated for scrapscript on 2023-11-29T02:06:16-05:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = "scrapscript" +primary_region = "ewr" + +[build] + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 256 + +[experimental] +cmd = ["serve", "--port", "8000", "--debug", "--fork"] diff --git a/repl.html b/repl.html new file mode 100644 index 00000000..e9066b81 --- /dev/null +++ b/repl.html @@ -0,0 +1,166 @@ + + + + + +Scrapscript Web REPL + + + + + + + + + +
+
+

See scrapscript.org for a slightly +out of date language reference.

+

This REPL sends programs to a server to be evaluated, but the server is +completely stateless. All persistence is in the browser.

+
+
+ + +
+
+Output: +
+
+>>> +
+ +
+ + + diff --git a/scrapscript.py b/scrapscript.py index 101e2320..2e396989 100755 --- a/scrapscript.py +++ b/scrapscript.py @@ -4,18 +4,21 @@ import code import dataclasses import enum +import http.server import json import logging import os import re +import socketserver import sys import typing import unittest import urllib.request +import urllib.parse from dataclasses import dataclass from enum import auto from types import FunctionType, ModuleType -from typing import Any, Callable, Dict, Mapping, Optional, Union +from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Union readline: Optional[ModuleType] try: @@ -1320,6 +1323,96 @@ def deserialize(msg: str) -> Object: return Object.deserialize(decoded) +class ScrapMonad: + def __init__(self, env: Env) -> None: + assert isinstance(env, dict) # for .copy() + self.env: Env = env.copy() + + def bind(self, exp: Object) -> Tuple[Object, "ScrapMonad"]: + env = self.env + result = eval_exp(env, exp) + if isinstance(result, EnvObject): + return result, ScrapMonad({**env, **result.env}) + return result, ScrapMonad({**env, "_": result}) + + +ASSET_DIR = os.path.dirname(__file__) + + +class ScrapReplServer(http.server.SimpleHTTPRequestHandler): + def do_GET(self) -> None: + logger.debug("GET %s", self.path) + parsed_path = urllib.parse.urlsplit(self.path) + query = urllib.parse.parse_qs(parsed_path.query) + logging.debug("PATH %s", parsed_path) + logging.debug("QUERY %s", query) + if parsed_path.path == "/repl": + return self.do_repl() + if parsed_path.path == "/eval": + try: + return self.do_eval(query) + except Exception as e: + self.send_response(400) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(str(e).encode("utf-8")) + return + if parsed_path.path == "/style.css": + self.send_response(200) + self.send_header("Content-type", "text/css") + self.end_headers() + with open(os.path.join(ASSET_DIR, "style.css"), "rb") as f: + self.wfile.write(f.read()) + return + return self.do_404() + + def do_repl(self) -> None: + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + with open(os.path.join(ASSET_DIR, "repl.html"), "rb") as f: + self.wfile.write(f.read()) + return + + def do_404(self) -> None: + self.send_response(404) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b"""try hitting /repl""") + return + + def do_eval(self, query: Dict[str, Any]) -> None: + exp = query.get("exp") + if exp is None: + raise TypeError("Need expression to evaluate") + if len(exp) != 1: + raise TypeError("Need exactly one expression to evaluate") + exp = exp[0] + tokens = tokenize(exp) + ast = parse(tokens) + env = query.get("env") + if env is None: + env = STDLIB + else: + if len(env) != 1: + raise TypeError("Need exactly one env") + env_object = deserialize(env[0]) + assert isinstance(env_object, EnvObject) + env = env_object.env + logging.debug("env is %s", env) + monad = ScrapMonad(env) + result, next_monad = monad.bind(ast) + serialized = EnvObject(next_monad.env).serialize() + encoded = bencode(serialized) + response = {"env": encoded.decode("utf-8"), "result": str(result)} + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.send_header("Cache-Control", "max-age=3600") + self.end_headers() + self.wfile.write(json.dumps(response).encode("utf-8")) + return + + class TokenizerTests(unittest.TestCase): def test_tokenize_digit(self) -> None: self.assertEqual(tokenize("1"), [IntLit(1)]) @@ -3918,6 +4011,21 @@ def test_serialize_function(self) -> None: ) +class ScrapMonadTests(unittest.TestCase): + def test_create_copies_env(self) -> None: + env = {"a": Int(123)} + result = ScrapMonad(env) + self.assertEqual(result.env, env) + self.assertIsNot(result.env, env) + + def test_bind_returns_new_monad(self) -> None: + env = {"a": Int(123)} + orig = ScrapMonad(env) + result, next_monad = orig.bind(Assign(Var("b"), Int(456))) + self.assertEqual(orig.env, {"a": Int(123)}) + self.assertEqual(next_monad.env, {"a": Int(123), "b": Int(456)}) + + class PrettyPrintTests(unittest.TestCase): def test_pretty_print_int(self) -> None: obj = Int(1) @@ -4254,6 +4362,21 @@ def test_command(args: argparse.Namespace) -> None: unittest.main(argv=[__file__, *args.unittest_args]) +def serve_command(args: argparse.Namespace) -> None: + if args.debug: + logging.basicConfig(level=logging.DEBUG) + server: Union[type[socketserver.TCPServer], type[socketserver.ForkingTCPServer]] + if args.fork: + server = socketserver.ForkingTCPServer + else: + server = socketserver.TCPServer + server.allow_reuse_address = True + with server(("", args.port), ScrapReplServer) as httpd: + host, port = httpd.server_address + print(f"serving at http://{host!s}:{port}") + httpd.serve_forever() + + def main() -> None: parser = argparse.ArgumentParser(prog="scrapscript") subparsers = parser.add_subparsers(dest="command") @@ -4277,6 +4400,12 @@ def main() -> None: apply.add_argument("program") apply.add_argument("--debug", action="store_true") + serve = subparsers.add_parser("serve") + serve.set_defaults(func=serve_command) + serve.add_argument("--port", type=int, default=8000) + serve.add_argument("--debug", action="store_true") + serve.add_argument("--fork", action="store_true") + args = parser.parse_args() if not args.command: args.debug = False diff --git a/style.css b/style.css new file mode 100644 index 00000000..ef1f33be --- /dev/null +++ b/style.css @@ -0,0 +1,284 @@ +html { + font-size: 100%; + background-color: black; +} +body { + font-family: "Nunito Sans", sans-serif; + font-weight: 400; + line-height: 1.75; + margin: 0; + max-width: 55rem; +} +p { + margin-bottom: 1rem; +} +div { + color: #d4d4d4; +} + +a:link { + text-decoration: none; + text-decoration-thickness: 1px; + text-underline-offset: 0.15rem; + text-decoration-style: dotted; +} +a { + color: #7ec699; +} + +ul, +ol { + padding-left: 1.5rem; +} + +blockquote { + color: #d4d4d4; + border-left: 2px dotted #666; + margin-left: 0.5rem; + padding-left: 1.5rem; + margin-bottom: 2rem; +} + +h1, +h2, +h3, +h4, +h5 { + margin: 3rem 0 1.38rem; + font-family: "Rubik", sans-serif; + font-weight: 500; + line-height: 1.3; + text-transform: capitalize; +} +h1 { + font-weight: 700; + text-transform: uppercase; + margin-top: 0; + letter-spacing: 1px; +} +h2 { + margin-top: 5rem; + padding-top: 2rem; + border-top: 2px dotted #666; +} +h3 { + margin-top: 3rem; +} + +h1 { + font-size: 4.209rem; +} +h2 { + font-size: 3.157rem; +} +h3 { + font-size: 2.369rem; +} +h4 { + font-size: 1.777rem; +} +h5 { + font-size: 1.333rem; +} +small, +.text_small { + font-size: 0.75rem; +} + +header > div, +footer > div { + padding: 2rem 2rem; + display: flex; + align-items: center; + gap: 1rem; + text-transform: uppercase; + font-weight: 600; + line-height: 2; + font-size: 0.875rem; + max-width: 35rem; + margin: 0 auto; +} +header a, +footer a { + color: #cc99cd; + text-decoration: none; + max-height: 1.75rem; +} +header img, +footer img { + height: 1.75rem; + -webkit-filter: invert(1); + filter: invert(1); + padding-right: 0.5rem; +} +footer { + margin-bottom: 6rem; +} + +main { + max-width: 35rem; + margin: 0 auto; + background-color: black; +} + +a:hover { + opacity: 0.8; +} + +pre { + overflow-x: scroll; +} +code, +pre, +code[class*="language-"], +pre[class*="language-"] { + font-family: "Fira Code", Monaco, Menlo, Consolas, "Courier New", monospace; + position: relative; + font-size: 0.8rem; +} +pre[class*="language-"] { + border-radius: 5px; + padding: 0.5em 0.5em; + margin: 0em; + z-index: 2; +} +code, +pre > code { + font-size: 0.8rem; + line-height: 1.42; + -webkit-text-size-adjust: 100%; +} +pre > code { + font-size: 0.8rem; + margin-left: 2.5%; + display: block; +} + +:not(pre) > code[class*="language-"], pre[class*="language-"] { + background: #2d2d2d; +} +pre[class*="language-"] + pre[class*="language-"].result { + background-color: #202020; + position: relative; + opacity: 0.9; +} + +pre[class*="language-"].result { + z-index: 1; +} +input[type="text"], textarea { + background-color: #2d2d2d; + outline: none; + color: #ccc; +} + +/* +main { + margin: 2rem; + max-width: 650px; +} +img { + max-width: 600px; + width: 95vw; +} +ul { + list-style-type: circle; + padding-left: 20px; +} + +p, dl, ol, ul { + line-height: 1.8rem; + max-width: 50ch; + color: #CCC; +} + +html { + background-color: #151515; + color: #eee; +} +a:link { + text-decoration: none; + text-decoration-thickness: 1px; + text-underline-offset: 0.15rem; + text-decoration-style: dotted; +} +a { + color: #A0D2A2; +} +a:visited { +} +a:active { +} + +h1 { + font-weight: 900; + font-size: 3rem; + letter-spacing: 0.1rem; + text-transform: uppercase; +} +h1 a, h1 a:visited, h1 a:active { + color: white; +} +h2 { + font-size: 1.8rem; + margin-top: 4.5rem; + padding-top: 2rem; + border-top: 1px solid #666; +} +h3 { + font-size: 1.4rem; + margin-top: 3rem; + font-weight: 600; +} + +@media (min-width: 760px) { + main { + margin: 3rem auto; + } + h1, h2 { + margin-left: -1rem; + } +} + +h1 { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-items: left; + justify-content: left; + line-height: 1.5; +} +h1 a { + line-height: 0; +} +h1 img { + width: 2.5rem; + height: 2.5rem; + -webkit-filter: invert(1); + filter: invert(1); +} +#coming { + font-weight: 500; + font-size: 0.5rem; + padding: 0.5rem; + border: 1px solid white; + border-radius: 5px; + display: inline-block; + order: 1; +} +@media (max-width: 505px) { + #title { + display: none; + } +} +@media (max-width: 760px) { + h1 { + font-size: 2rem; + } +} + +#table-of-contents, h2, h3 { + text-transform: capitalize; +} +*/