├── requirements.txt ├── recto-pad.png ├── figures.sketch ├── recto-cover.png ├── LICENSE ├── utils.py ├── README.md ├── recto.py ├── .gitignore ├── interpreter.py ├── parser.py └── recto-pad.html /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==2.0.2 2 | -------------------------------------------------------------------------------- /recto-pad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhagiwara/recto/HEAD/recto-pad.png -------------------------------------------------------------------------------- /figures.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhagiwara/recto/HEAD/figures.sketch -------------------------------------------------------------------------------- /recto-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhagiwara/recto/HEAD/recto-cover.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Masato Hagiwara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Union 3 | 4 | @dataclass 5 | class Token: 6 | row: int 7 | col: int 8 | token: str 9 | 10 | def __str__(self, depth=0): 11 | indent = " " * depth 12 | return f"""{indent}Token(row={self.row}, col={self.col}, token={self.token!r})""" 13 | 14 | @dataclass 15 | class Rect: 16 | type: str 17 | tl_row: int 18 | tl_col: int 19 | height: int 20 | width: int 21 | children: List[Union["Rect", Token]] = field(default_factory=list) 22 | 23 | def __str__(self, depth=0): 24 | indent = " " * depth 25 | children_str = "".join(child.__str__(depth + 2) + "\n" for child in self.children) 26 | return ( 27 | f"{indent}Rect(\n" 28 | f"{indent} type='{self.type}', \n" 29 | f"{indent} pos=({self.tl_row},{self.tl_col}), \n" 30 | f"{indent} size={self.height}x{self.width}, \n" 31 | f"{indent} children=\n{children_str}{indent})" 32 | ) 33 | 34 | FirstRowItem = Union[Rect, Token] 35 | 36 | # Represents an opening we haven't closed yet (top edge, row, or column) 37 | @dataclass 38 | class Frame: 39 | tl_col: int 40 | kind: str # '/-' '|-' '^-' 41 | rect_type: str = "" # suffix after '/-' or '^-' (may be "") 42 | first_row_items: List[FirstRowItem] = field(default_factory=list) 43 | 44 | TL = "/-"; TR = "-\\" 45 | BL = "\\-"; BR = "-/" 46 | ROW_L = "|-"; ROW_R = "-|" 47 | COL_T = "^-"; COL_B = "v-" 48 | 49 | __all__ = [ 50 | "Token", "Rect", "Frame", "FirstRowItem", 51 | "TL", "TR", "BL", "BR", "ROW_L", "ROW_R", "COL_T", "COL_B", 52 | ] 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recto — a truly 2D language 2 | Masato Hagiwara 3 | 4 | ![Recto Cover](recto-cover.png) 5 | 6 | [Open in Recto Pad](https://masatohagiwara.net/recto-pad.html#code=JTVCJTVCJTIyJTJGLSUyMiUyQyUyMiUyMiUyQyUyMiUzRCUzRCUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMi0lNUMlNUMlMjIlNUQlMkMlNUIlMjIlMjIlMkMlMjIlMkYtJTIyJTJDJTIyJTIyJTJDJTIybWF0bXVsJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyLSU1QyU1QyUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiU1RCUyQyU1QiUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyRi1tJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyLSU1QyU1QyUyMiUyQyUyMiUyMiUyQyUyMiU1RS1tJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTVFLW0lMjIlMkMlMjIlMjIlNUQlMkMlNUIlMjIlMjIlMkMlMjIlMjIlMkMlMjIlMjIlMkMlMjIxJTIyJTJDJTIyJTIyJTJDJTIyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMjUlMjIlMkMlMjIlMjIlMkMlMjIlMjIlMkMlMjIxNyUyMiUyQyUyMiUyMiU1RCUyQyU1QiUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMjMlMjIlMkMlMjIlMjIlMkMlMjI0JTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyNiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMjM5JTIyJTJDJTIyJTIyJTVEJTJDJTVCJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTVDJTVDLSUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMi0lMkYlMjIlMkMlMjIlMjIlMkMlMjJ2LSUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMnYtJTIyJTJDJTIyJTIyJTVEJTJDJTVCJTIyJTIyJTJDJTIyJTVDJTVDLSUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMiUyMiUyQyUyMi0lMkYlMjIlMkMlMjIlMjIlMkMlMjIlMjIlMkMlMjIlMjIlNUQlMkMlNUIlMjIlNUMlNUMtJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyJTIyJTJDJTIyLSUyRiUyMiU1RCU1RA==) 7 | 8 | [📄 **Read the full documentation**](https://masatohagiwara.net/recto.html) 9 | [🚀 **Try in Google Colab**](https://colab.research.google.com/drive/1sDgWNI-QUkamf5_kZblX9R7Vxzk7_WB7?usp=sharing) 10 | [🖋 **Recto Pad**](https://masatohagiwara.net/recto-pad.html) 11 | 12 | --- 13 | 14 | ## TL;DR 15 | 16 | Recto is a 2D programming language that uses nested rectangles as its core syntax, encoding structure and recursion directly in space instead of a linear stream of text. Recto explores new ways to write, parse, and reason about code—and even natural language—spatially. 17 | -------------------------------------------------------------------------------- /recto.py: -------------------------------------------------------------------------------- 1 | from parser import Parser 2 | from interpreter import lower, eval_ast, GLOBAL 3 | 4 | test_cases = [ 5 | """ 6 | /- -\\ 7 | + 8 | 1 9 | 2 10 | \\- -/ 11 | """, 12 | 13 | """ 14 | /- -\\ 15 | ^- 16 | print 17 | |- print "Recto" -| 18 | "Hello" 19 | v- 20 | \\- -/ 21 | """, 22 | 23 | """ 24 | /- + -\\ 25 | /- -\\ 26 | - 27 | 5 4 28 | \\- -/ 29 | 30 | 3 2 31 | \\- -/ 32 | """, 33 | 34 | """ 35 | |- + 1 |- + 2 3 -| 4 -| 36 | """, 37 | 38 | """ 39 | ^- 40 | + 41 | 1 42 | ^- 43 | + 44 | 2 45 | 3 46 | v- 47 | 4 48 | v- 49 | """, 50 | 51 | """ 52 | /- == -\\ 53 | /- matmul -\\ 54 | /-m -\\ ^-m ^-m 55 | 1 2 5 17 56 | 3 4 6 39 57 | \\- -/ v- v- 58 | \\- -/ 59 | \\- -/ 60 | """, 61 | 62 | """ 63 | /- for i -\\ 64 | |- range 1 6 -| 65 | 66 | /- -\\ 67 | print i 68 | \\- -/ 69 | 70 | \\- -/ 71 | """, 72 | 73 | """ 74 | /- -\\ 75 | |- = a 1 -| 76 | |- = b 2 -| 77 | |- = c 3 -| 78 | 79 | /- -\\ 80 | print a /- -\\ 81 | \\- -/ print b 82 | \\- -/ 83 | /- -\\ 84 | print c 85 | \\- -/ 86 | \\- -/ 87 | """, 88 | 89 | """ 90 | /- -\\ 91 | /- fn times -\\ 92 | a b ^- ^- 93 | * times 94 | a 2 95 | b 3 96 | v- v- 97 | \\- -/ 98 | \\- -/ 99 | """, 100 | 101 | """ 102 | /- |- = n 123 -| -\\ 103 | /- if -\\ 104 | /- -\\ 105 | == 0 106 | |- % n 2 -| 107 | \\- -/ 108 | "even" "odd" 109 | \\- -/ 110 | \\- -/ 111 | """, 112 | 113 | """ 114 | /- get -\\ 115 | /-d -\\ 116 | "a" 1 117 | "b" 2 118 | "c" 3 119 | \\- -/ 120 | "a" 121 | \\- -/ 122 | """, 123 | 124 | """ 125 | /- in? -\\ 126 | /-s -\\ 127 | "x" 128 | "y" 129 | "z" 130 | \\- -/ 131 | "z" 132 | \\- -/ 133 | """, 134 | 135 | """ 136 | /- for n |- range 1 21 -| -\\ 137 | /- print -\\ 138 | /- if |- == |- % n 15 -| 0 -| "FizzBuzz" -\\ 139 | /- if |- == |- % n 3 -| 0 -| "Fizz" -\\ 140 | /- if |- == |- % n 5 -| 0 -| "Buzz" -\\ 141 | n 142 | \\- -/ 143 | \\- -/ 144 | \\- -/ 145 | \\- -/ 146 | \\- -/ 147 | """ 148 | ] 149 | 150 | parser = Parser() 151 | 152 | for i, test_case in enumerate(test_cases): 153 | print(f"Test case {i}:") 154 | root_rect = parser.parse_recto(test_case) 155 | print(f"root_rect: {root_rect}") 156 | ast = lower(root_rect) 157 | print(f"ast: {ast}") 158 | print(f"eval: {eval_ast(ast, GLOBAL)}") 159 | print("--------------------------------") 160 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | # Abstra 179 | # Abstra is an AI-powered process automation framework. 180 | # Ignore directories containing user credentials, local state, and settings. 181 | # Learn more at https://abstra.io/docs 182 | .abstra/ 183 | 184 | # Visual Studio Code 185 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 186 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 187 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 188 | # you could uncomment the following to ignore the entire vscode folder 189 | # .vscode/ 190 | 191 | # Ruff stuff: 192 | .ruff_cache/ 193 | 194 | # PyPI configuration file 195 | .pypirc 196 | 197 | # Marimo 198 | marimo/_static/ 199 | marimo/_lsp/ 200 | __marimo__/ 201 | 202 | # Streamlit 203 | .streamlit/secrets.toml 204 | -------------------------------------------------------------------------------- /interpreter.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, List, Tuple, Optional, Union 3 | 4 | from utils import Rect, Token 5 | 6 | import math 7 | import numpy as np 8 | 9 | # ---------- AST nodes ---------- 10 | @dataclass 11 | class Literal: value: Any 12 | @dataclass 13 | class Symbol: name: str 14 | 15 | @dataclass 16 | class Do: items: List[Any] 17 | @dataclass 18 | class Assign: name: str; value: Any 19 | 20 | @dataclass 21 | class Call: head: Any; args: List[Any] 22 | @dataclass 23 | class If: cond: Any; then: Any; els: Any 24 | @dataclass 25 | class For: var: str; rng: Any; body: Any 26 | @dataclass 27 | class Fn: name: str; params: List[str]; body: Any 28 | 29 | @dataclass 30 | class Matrix: rows: List[List[Any]] 31 | @dataclass 32 | class DictLit: pairs: List[Tuple[Any, Any]] 33 | @dataclass 34 | class SetLit: items: List[Any] 35 | 36 | # ---------- helpers to read your parse tree ---------- 37 | def is_rect(n): return isinstance(n, Rect) 38 | def is_tok(n): return isinstance(n, Token) 39 | 40 | def atomize(tok: str): 41 | # numbers 42 | if tok.startswith('"') and tok.endswith('"') and len(tok) >= 2: 43 | return Literal(tok[1:-1]) 44 | try: 45 | return Literal(int(tok)) 46 | except ValueError: 47 | pass 48 | try: 49 | return Literal(float(tok)) 50 | except ValueError: 51 | pass 52 | # booleans (optional) 53 | if tok == "true": return Literal(True) 54 | if tok == "false": return Literal(False) 55 | return Symbol(tok) 56 | 57 | def node_pos(n): 58 | # for sorting: rects by (tl_row, tl_col); tokens by (row, col) 59 | if is_rect(n): return (n.tl_row, n.tl_col) 60 | if is_tok(n): return (n.row, n.col) 61 | return (0, 0) 62 | 63 | def row_major_children(rect: Rect): 64 | # your parser already attaches children; sort for deterministic order 65 | return sorted(rect.children, key=node_pos) 66 | 67 | 68 | # ---------- lower typed rects ---------- 69 | def lower_matrix(rect: Rect): 70 | # group children by row 71 | byrow = {} 72 | for ch in rect.children: 73 | r, c = node_pos(ch) 74 | byrow.setdefault(r, []).append(ch) 75 | rows = [] 76 | for r in sorted(byrow): 77 | rows.append([lower(x) for x in sorted(byrow[r], key=node_pos)]) 78 | return Matrix(rows) 79 | 80 | def lower_dict(rect: Rect): 81 | elems = [lower(x) for x in row_major_children(rect)] 82 | pairs = list(zip(elems[0::2], elems[1::2])) 83 | return DictLit(pairs) 84 | 85 | def lower_set(rect: Rect): 86 | elems = [lower(x) for x in row_major_children(rect)] 87 | return SetLit(elems) 88 | 89 | def lower_call_or_special(rect: Rect): 90 | elems = [lower(x) for x in row_major_children(rect)] 91 | if not elems: return Do([]) # empty rect -> no operation 92 | head, rest = elems[0], elems[1:] 93 | 94 | # special forms 95 | if isinstance(head, Symbol) and head.name == "=": 96 | if len(rest) != 2 or not isinstance(rest[0], Symbol): 97 | raise SyntaxError("= expects: (= )") 98 | return Assign(rest[0].name, rest[1]) 99 | 100 | if isinstance(head, Symbol) and head.name == "if": 101 | # if cond then else 102 | if len(rest) != 3: 103 | raise SyntaxError("if expects 3 parts: cond, then, else") 104 | return If(rest[0], rest[1], rest[2]) 105 | 106 | if isinstance(head, Symbol) and head.name == "for": 107 | # for var range body 108 | if len(rest) != 3 or not isinstance(rest[0], Symbol): 109 | raise SyntaxError("for expects: var Symbol, range, body") 110 | return For(rest[0].name, rest[1], rest[2]) 111 | 112 | if isinstance(head, Symbol) and head.name == "fn": 113 | # fn name params... body 114 | if len(rest) < 2: 115 | raise SyntaxError("fn expects at least name and body") 116 | name = rest[0] 117 | body = rest[-1] 118 | params = [p.name for p in rest[1:-1] if isinstance(p, Symbol)] 119 | return Fn(name, params, body) 120 | 121 | if isinstance(head, Call) or isinstance(head, Assign) or isinstance(head, Fn): 122 | return Do(elems) 123 | 124 | return Call(head, rest) 125 | 126 | def lower(node) -> Any: 127 | if is_tok(node): return atomize(node.token) 128 | if not is_rect(node): return node 129 | 130 | tag = node.type or "" # "" for generic rects 131 | if tag == "m": return lower_matrix(node) 132 | if tag == "d": return lower_dict(node) 133 | if tag == "s": return lower_set(node) 134 | 135 | # generic application 136 | return lower_call_or_special(node) 137 | 138 | # ---------- runtime ---------- 139 | from dataclasses import dataclass 140 | 141 | class Env(dict): 142 | def __init__(self, parent=None): super().__init__(); self.parent = parent 143 | def getv(self, k): 144 | if k in self: return self[k] 145 | if self.parent: return self.parent.getv(k) 146 | raise NameError(k) 147 | 148 | @dataclass 149 | class NativeFn: 150 | fn: Any 151 | 152 | @dataclass 153 | class UserFn: 154 | params: List[str] 155 | body: Any 156 | env: Env # closure 157 | 158 | def eval_ast(node, env: Env): 159 | if isinstance(node, Literal): 160 | return node.value 161 | if isinstance(node, Symbol): 162 | return env.getv(node.name) 163 | 164 | if isinstance(node, Matrix): 165 | return np.array([[eval_ast(x, env) for x in row] for row in node.rows]) 166 | if isinstance(node, DictLit): 167 | return {eval_ast(k, env): eval_ast(v, env) for k, v in node.pairs} 168 | if isinstance(node, SetLit): 169 | return set(eval_ast(x, env) for x in node.items) 170 | 171 | if isinstance(node, If): 172 | return eval_ast(node.then, env) if eval_ast(node.cond, env) else eval_ast(node.els, env) 173 | 174 | if isinstance(node, Fn): 175 | fn = UserFn(node.params, node.body, env) 176 | env[node.name.name] = fn 177 | return fn 178 | 179 | if isinstance(node, For): 180 | lo, hi = eval_range(eval_ast(node.rng, env)) 181 | for i in range(lo, hi): 182 | inner = Env(env); inner[node.var] = i 183 | eval_ast(node.body, inner) 184 | return None 185 | 186 | if isinstance(node, Call): 187 | cal = eval_ast(node.head, env) 188 | args = [eval_ast(a, env) for a in node.args] 189 | if isinstance(cal, NativeFn): 190 | return cal.fn(*args) 191 | if isinstance(cal, UserFn): 192 | inner = Env(cal.env) 193 | for p, v in zip(cal.params, args): inner[p] = v 194 | return eval_ast(cal.body, inner) 195 | raise TypeError(f"attempted to call non-function: {cal}") 196 | 197 | if isinstance(node, Do): 198 | last = None 199 | for item in node.items: 200 | last = eval_ast(item, env) 201 | return last 202 | 203 | if isinstance(node, Assign): 204 | value = eval_ast(node.value, env) 205 | env[node.name] = value 206 | return None 207 | 208 | raise TypeError(f"unknown AST node: {node}") 209 | 210 | def eval_range(val): 211 | # Allow our simple 'range' builtin to return [lo, hi] 212 | if isinstance(val, list) and len(val) == 2 and all(isinstance(x, (int,float)) for x in val): 213 | return int(val[0]), int(val[1]) 214 | raise TypeError("range must evaluate to [lo, hi] list") 215 | 216 | # ---------- built-ins ---------- 217 | GLOBAL = Env() 218 | GLOBAL.update({ 219 | "+": NativeFn(lambda *xs: sum(xs)), 220 | "-": NativeFn(lambda a,b: a-b), 221 | "*": NativeFn(lambda *xs: math.prod(xs)), 222 | "/": NativeFn(lambda a,b: a/b), 223 | "%": NativeFn(lambda a,b: a%b), 224 | "==": NativeFn(lambda a,b: a==b), 225 | "print": NativeFn(lambda *xs: print(*xs)), 226 | "range": NativeFn(lambda a,b: [a,b]), # our eval_range consumes [lo,hi] 227 | "matmul": NativeFn(lambda a,b: np.matmul(a,b)), 228 | "get": NativeFn(lambda d,k: d[k]), 229 | "in?": NativeFn(lambda s,x: x in s), 230 | }) 231 | -------------------------------------------------------------------------------- /parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import defaultdict 3 | from typing import List, Optional 4 | 5 | from utils import ( 6 | Token, Rect, Frame, 7 | TL, TR, BL, BR, ROW_L, ROW_R, COL_T, COL_B 8 | ) 9 | 10 | class Parser: 11 | def __init__(self): 12 | self.row_stack: List[Frame] = [] 13 | self.col_stack = defaultdict(list) 14 | self.all_rects: List[Rect] = [] 15 | 16 | def _reset_state(self): 17 | self.row_stack.clear() 18 | self.col_stack.clear() 19 | self.all_rects.clear() 20 | 21 | def _peek_row(self) -> Optional[Frame]: 22 | return self.row_stack[-1] if self.row_stack else None 23 | 24 | def _peek_col_rect(self, c: int) -> Optional[Rect]: 25 | stack = self.col_stack[c] 26 | return stack[-1] if stack and isinstance(stack[-1], Rect) else None 27 | 28 | def _attach_to_parent_if_any(self, rect: Rect): 29 | # parent is whichever rect currently covers rect.tl_col (by column stack) 30 | parent = self._peek_col_rect(rect.tl_col) 31 | if parent and parent.tl_col <= rect.tl_col <= parent.tl_col + parent.width - 1: 32 | # check if rect is fully contained in parent 33 | if rect.tl_col + rect.width - 1 <= parent.tl_col + parent.width - 1: 34 | parent.children.append(rect) 35 | 36 | def _push_rect_to_columns(self, rect: Rect): 37 | for c in range(rect.tl_col, rect.tl_col + rect.width): 38 | self.col_stack[c].append(rect) 39 | 40 | def _pop_rect_from_columns(self, rect: Rect): 41 | for c in range(rect.tl_col, rect.tl_col + rect.width): 42 | if self.col_stack[c] and self.col_stack[c][-1] is rect: 43 | self.col_stack[c].pop() 44 | 45 | def detect_rect_top_edge(self, lex, row, col): 46 | 47 | # note: this function returns True if the lexeme is part of a rect top edge 48 | if lex.startswith(TL): 49 | rect_type = lex[len(TL):] 50 | self.row_stack.append(Frame(tl_col=col, kind=TL, rect_type=rect_type)) 51 | return True 52 | 53 | if lex == TR: 54 | fr = self._peek_row() 55 | if not fr or fr.kind != TL: 56 | raise SyntaxError(f"Unmatched {TR} at ({row},{col})") 57 | 58 | tl_col = fr.tl_col 59 | tr_col = col 60 | rect = Rect( 61 | type=fr.rect_type, 62 | tl_row=row, 63 | tl_col=tl_col, 64 | width=tr_col-tl_col+1, 65 | height=-1, 66 | ) 67 | rect.children.extend(fr.first_row_items) 68 | self.row_stack.pop() 69 | 70 | # check parent rect 71 | self._attach_to_parent_if_any(rect) 72 | self._push_rect_to_columns(rect) 73 | 74 | return True 75 | 76 | return False 77 | 78 | 79 | def detect_rect_bottom_edge(self, lex, row, col): 80 | # note: this function returns True if the lexeme is part of a rect bottom edge 81 | if lex == BL: 82 | self.row_stack.append(Frame(tl_col=col, kind=BL)) 83 | return True 84 | 85 | if lex == BR: 86 | fr = self._peek_row() 87 | if not fr or fr.kind != BL: 88 | raise SyntaxError(f"Unmatched {BR} at ({row},{col})") 89 | 90 | bl_col = fr.tl_col 91 | br_col = col 92 | 93 | # check if rect is already in column stack (at bl_col) 94 | rect = self._peek_col_rect(bl_col) 95 | if not rect: 96 | raise SyntaxError(f"No open rect spanning column {bl_col} at ({row},{col})") 97 | 98 | # ensure width matches 99 | if rect.width != br_col - bl_col + 1 or rect.tl_col != bl_col: 100 | raise SyntaxError(f"Bottom edge does not match top edge at ({row},{col})") 101 | 102 | rect.height = row - rect.tl_row + 1 103 | self._pop_rect_from_columns(rect) 104 | self.row_stack.pop() 105 | self.all_rects.append(rect) 106 | 107 | return True 108 | 109 | return False 110 | 111 | def detect_row_wise_rect(self, lex, row, col): 112 | if lex.startswith(ROW_L): 113 | rect_type = lex[len(ROW_L):] 114 | self.row_stack.append(Frame(tl_col=col, kind=ROW_L, rect_type=rect_type)) 115 | return True 116 | 117 | if lex == ROW_R: 118 | fr = self._peek_row() 119 | if not fr or fr.kind != ROW_L: 120 | raise SyntaxError(f"Unmatched {ROW_R} at ({row},{col})") 121 | 122 | tl_col = fr.tl_col 123 | tr_col = col 124 | rect = Rect( 125 | type=fr.rect_type, 126 | tl_row=row, 127 | tl_col=tl_col, 128 | width=tr_col-tl_col+1, 129 | height=1, 130 | ) 131 | rect.children.extend(fr.first_row_items) 132 | self.row_stack.pop() 133 | 134 | # NEW: if there's an enclosing frame on the same row, attach as first-row content 135 | enclosing = self._peek_row() 136 | if enclosing and enclosing.kind in (TL, ROW_L): 137 | enclosing.first_row_items.append(rect) 138 | return True 139 | 140 | self._attach_to_parent_if_any(rect) 141 | self.all_rects.append(rect) 142 | 143 | return True 144 | 145 | return False 146 | 147 | 148 | def detect_column_wise_rect(self, lex, row, col): 149 | if lex.startswith(COL_T): 150 | rect_type = lex[len(COL_T):] 151 | rect = Rect( 152 | type=rect_type, 153 | tl_row=row, 154 | tl_col=col, 155 | width=1, 156 | height=-1 157 | ) 158 | 159 | self._attach_to_parent_if_any(rect) 160 | self.col_stack[col].append(rect) 161 | return True 162 | 163 | if lex == COL_B: 164 | rect = self._peek_col_rect(col) 165 | if not rect or rect.tl_col != col or rect.width != 1: 166 | raise SyntaxError(f"Unmatched {COL_B} at ({row},{col})") 167 | rect.height = row - rect.tl_row + 1 168 | self.col_stack[col].pop() 169 | self.all_rects.append(rect) 170 | 171 | return True 172 | 173 | return False 174 | 175 | def detect_token(self, lex, row, col): 176 | if not lex: 177 | return 178 | 179 | fr = self._peek_row() 180 | if fr and fr.kind in (TL, ROW_L): 181 | fr.first_row_items.append(Token(row, col, lex)) 182 | return 183 | 184 | # check if token is in parent rect 185 | parent = self._peek_col_rect(col) 186 | if parent and parent.tl_col <= col <= parent.tl_col + parent.width - 1: 187 | parent.children.append(Token(row, col, lex)) 188 | 189 | def parse_recto(self,recto_code): 190 | self._reset_state() 191 | 192 | # step 1: tokenize into a 2D array of lexemes 193 | x = [] 194 | for line in recto_code.splitlines(): 195 | lexemes = re.sub(r' +', '\t', line).split('\t') 196 | x.append(lexemes) 197 | 198 | # scan lexemes in row-major order 199 | for row in range(len(x)): 200 | for col in range(len(x[row])): 201 | lex = x[row][col] 202 | 203 | if self.detect_rect_top_edge(lex, row, col): 204 | continue 205 | 206 | if self.detect_rect_bottom_edge(lex, row, col): 207 | continue 208 | 209 | if self.detect_row_wise_rect(lex, row, col): 210 | continue 211 | 212 | if self.detect_column_wise_rect(lex, row, col): 213 | continue 214 | 215 | self.detect_token(lex, row, col) 216 | 217 | if not self.all_rects: 218 | raise SyntaxError("No rects parsed.") 219 | return self.all_rects[-1] # assume last closed is the root 220 | 221 | __all__ = ["Parser"] 222 | -------------------------------------------------------------------------------- /recto-pad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Recto Pad 7 | 8 | 109 | 110 | 111 |
112 |

Recto Pad

113 | 114 | 115 |
116 | 117 |
118 |
119 |
120 |

Grid

121 |
122 | Draw: 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |
133 |
134 |
135 |
136 | 137 |
138 | 139 | 140 | 141 | 142 |
143 | 144 |
145 |
146 |

Code

147 | 148 |

149 | Copy and paste this code into 150 | Google Colab 151 | to run it. 152 |

153 | 154 | 155 | 156 | 157 |
158 | 159 |
160 |

Resources

161 | 166 |
167 |
168 |
169 | 170 | 752 | 753 | 754 | --------------------------------------------------------------------------------