snail-lang 0.7.1__cp38-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snail/__init__.py +35 -0
- snail/_native.pyd +0 -0
- snail/cli.py +386 -0
- snail/runtime/__init__.py +250 -0
- snail/runtime/augmented.py +49 -0
- snail/runtime/compact_try.py +13 -0
- snail/runtime/lazy_file.py +41 -0
- snail/runtime/lazy_text.py +50 -0
- snail/runtime/regex.py +37 -0
- snail/runtime/structured_accessor.py +74 -0
- snail/runtime/subprocess.py +66 -0
- snail_lang-0.7.1.dist-info/METADATA +318 -0
- snail_lang-0.7.1.dist-info/RECORD +16 -0
- snail_lang-0.7.1.dist-info/WHEEL +4 -0
- snail_lang-0.7.1.dist-info/entry_points.txt +2 -0
- snail_lang-0.7.1.dist-info/licenses/LICENSE +20 -0
snail/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._native import __build_info__, compile, compile_ast, exec, parse, parse_ast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _resolve_version() -> str:
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
return version("snail-lang")
|
|
11
|
+
except Exception: # pragma: no cover - during development
|
|
12
|
+
return "0.0.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name: str):
|
|
16
|
+
if name == "__version__":
|
|
17
|
+
value = _resolve_version()
|
|
18
|
+
globals()["__version__"] = value
|
|
19
|
+
return value
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __dir__() -> list[str]:
|
|
24
|
+
return sorted(list(globals().keys()) + ["__version__"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"compile",
|
|
29
|
+
"compile_ast",
|
|
30
|
+
"exec",
|
|
31
|
+
"parse",
|
|
32
|
+
"parse_ast",
|
|
33
|
+
"__version__",
|
|
34
|
+
"__build_info__",
|
|
35
|
+
]
|
snail/_native.pyd
ADDED
|
Binary file
|
snail/cli.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from . import __build_info__, compile_ast, exec
|
|
8
|
+
|
|
9
|
+
_USAGE = (
|
|
10
|
+
"snail [options] -f <file> [args]...\n"
|
|
11
|
+
" snail [options] <code> [args]..."
|
|
12
|
+
)
|
|
13
|
+
_DESCRIPTION = "Snail programming language interpreter"
|
|
14
|
+
_BOOLEAN_FLAGS = frozenset("amPIvh")
|
|
15
|
+
_VALUE_FLAGS = frozenset("fbe")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _display_filename(filename: str) -> str:
|
|
19
|
+
if filename.startswith("snail:"):
|
|
20
|
+
return filename
|
|
21
|
+
return f"snail:{filename}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _trim_internal_prefix(stack, internal_files: set[str]) -> None:
|
|
25
|
+
if not stack:
|
|
26
|
+
return
|
|
27
|
+
trim_count = 0
|
|
28
|
+
for frame in stack:
|
|
29
|
+
filename = frame.filename
|
|
30
|
+
if filename.startswith("snail:"):
|
|
31
|
+
break
|
|
32
|
+
if filename in internal_files:
|
|
33
|
+
trim_count += 1
|
|
34
|
+
continue
|
|
35
|
+
if os.path.isabs(filename) and os.path.abspath(filename) in internal_files:
|
|
36
|
+
trim_count += 1
|
|
37
|
+
continue
|
|
38
|
+
break
|
|
39
|
+
if 0 < trim_count < len(stack):
|
|
40
|
+
del stack[:trim_count]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _trim_traceback_exception(tb_exc, internal_files: set[str]) -> None:
|
|
44
|
+
_trim_internal_prefix(tb_exc.stack, internal_files)
|
|
45
|
+
cause = getattr(tb_exc, "__cause__", None)
|
|
46
|
+
if cause is not None:
|
|
47
|
+
_trim_traceback_exception(cause, internal_files)
|
|
48
|
+
context = getattr(tb_exc, "__context__", None)
|
|
49
|
+
if context is not None:
|
|
50
|
+
_trim_traceback_exception(context, internal_files)
|
|
51
|
+
for group_exc in getattr(tb_exc, "exceptions", ()) or ():
|
|
52
|
+
_trim_traceback_exception(group_exc, internal_files)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _install_trimmed_excepthook() -> None:
|
|
56
|
+
entrypoint = os.path.abspath(sys.argv[0])
|
|
57
|
+
cli_path = os.path.abspath(__file__)
|
|
58
|
+
internal_files = {entrypoint, cli_path}
|
|
59
|
+
original_excepthook = sys.excepthook
|
|
60
|
+
|
|
61
|
+
def _snail_excepthook(
|
|
62
|
+
exc_type: type[BaseException],
|
|
63
|
+
exc: BaseException,
|
|
64
|
+
tb: object,
|
|
65
|
+
) -> None:
|
|
66
|
+
if exc_type is KeyboardInterrupt:
|
|
67
|
+
return original_excepthook(exc_type, exc, tb)
|
|
68
|
+
import traceback
|
|
69
|
+
|
|
70
|
+
tb_exc = traceback.TracebackException(
|
|
71
|
+
exc_type,
|
|
72
|
+
exc,
|
|
73
|
+
tb,
|
|
74
|
+
capture_locals=False,
|
|
75
|
+
)
|
|
76
|
+
_trim_traceback_exception(tb_exc, internal_files)
|
|
77
|
+
try:
|
|
78
|
+
import _colorize
|
|
79
|
+
|
|
80
|
+
colorize = _colorize.can_colorize(file=sys.stderr)
|
|
81
|
+
except Exception:
|
|
82
|
+
colorize = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
83
|
+
try:
|
|
84
|
+
formatted = tb_exc.format(colorize=colorize)
|
|
85
|
+
except TypeError:
|
|
86
|
+
formatted = tb_exc.format()
|
|
87
|
+
for line in formatted:
|
|
88
|
+
sys.stderr.write(line)
|
|
89
|
+
|
|
90
|
+
sys.excepthook = _snail_excepthook
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Args:
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
self.file: Optional[str] = None
|
|
96
|
+
self.awk = False
|
|
97
|
+
self.map = False
|
|
98
|
+
self.no_print = False
|
|
99
|
+
self.no_auto_import = False
|
|
100
|
+
self.debug = False
|
|
101
|
+
self.debug_snail_ast = False
|
|
102
|
+
self.version = False
|
|
103
|
+
self.help = False
|
|
104
|
+
self.begin_code: list[str] = []
|
|
105
|
+
self.end_code: list[str] = []
|
|
106
|
+
self.args: list[str] = []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _print_help(file=None) -> None:
|
|
110
|
+
if file is None:
|
|
111
|
+
file = sys.stdout
|
|
112
|
+
print(f"usage: {_USAGE}", file=file)
|
|
113
|
+
print("", file=file)
|
|
114
|
+
print(_DESCRIPTION, file=file)
|
|
115
|
+
print("", file=file)
|
|
116
|
+
print("options:", file=file)
|
|
117
|
+
print(" -f <file> read Snail source from file", file=file)
|
|
118
|
+
print(" -a, --awk awk mode", file=file)
|
|
119
|
+
print(" -m, --map map mode (process files one at a time)", file=file)
|
|
120
|
+
print(
|
|
121
|
+
" -b, --begin <code> begin block code (awk/map mode, repeatable)",
|
|
122
|
+
file=file,
|
|
123
|
+
)
|
|
124
|
+
print(
|
|
125
|
+
" -e, --end <code> end block code (awk/map mode, repeatable)",
|
|
126
|
+
file=file,
|
|
127
|
+
)
|
|
128
|
+
print(
|
|
129
|
+
" -P, --no-print disable auto-print of implicit return value",
|
|
130
|
+
file=file,
|
|
131
|
+
)
|
|
132
|
+
print(" -I, --no-auto-import disable auto-imports", file=file)
|
|
133
|
+
print(" --debug parse and compile, then print, do not run", file=file)
|
|
134
|
+
print(" --debug-snail-ast parse and print Snail AST, do not run", file=file)
|
|
135
|
+
print(" -v, --version show version and exit", file=file)
|
|
136
|
+
print(" -h, --help show this help message and exit", file=file)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _expand_short_options(argv: list[str]) -> list[str]:
|
|
140
|
+
expanded: list[str] = []
|
|
141
|
+
idx = 0
|
|
142
|
+
while idx < len(argv):
|
|
143
|
+
token = argv[idx]
|
|
144
|
+
if token == "--":
|
|
145
|
+
expanded.append(token)
|
|
146
|
+
expanded.extend(argv[idx + 1 :])
|
|
147
|
+
return expanded
|
|
148
|
+
if token == "-" or not token.startswith("-") or token.startswith("--"):
|
|
149
|
+
expanded.append(token)
|
|
150
|
+
idx += 1
|
|
151
|
+
continue
|
|
152
|
+
if len(token) == 2:
|
|
153
|
+
expanded.append(token)
|
|
154
|
+
idx += 1
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
flags = token[1:]
|
|
158
|
+
pos = 0
|
|
159
|
+
while pos < len(flags):
|
|
160
|
+
flag = flags[pos]
|
|
161
|
+
if flag in _BOOLEAN_FLAGS:
|
|
162
|
+
expanded.append(f"-{flag}")
|
|
163
|
+
pos += 1
|
|
164
|
+
continue
|
|
165
|
+
if flag in _VALUE_FLAGS:
|
|
166
|
+
remainder = flags[pos + 1 :]
|
|
167
|
+
if not remainder:
|
|
168
|
+
expanded.append(f"-{flag}")
|
|
169
|
+
pos += 1
|
|
170
|
+
continue
|
|
171
|
+
if all(
|
|
172
|
+
ch in _BOOLEAN_FLAGS or ch in _VALUE_FLAGS for ch in remainder
|
|
173
|
+
):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"option -{flag} requires an argument and must be last in a "
|
|
176
|
+
"combined flag group"
|
|
177
|
+
)
|
|
178
|
+
expanded.append(f"-{flag}")
|
|
179
|
+
expanded.append(remainder)
|
|
180
|
+
pos = len(flags)
|
|
181
|
+
break
|
|
182
|
+
raise ValueError(f"unknown option: -{flag}")
|
|
183
|
+
idx += 1
|
|
184
|
+
return expanded
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_args(argv: list[str]) -> _Args:
|
|
188
|
+
argv = _expand_short_options(argv)
|
|
189
|
+
args = _Args()
|
|
190
|
+
idx = 0
|
|
191
|
+
code_found = False
|
|
192
|
+
while idx < len(argv):
|
|
193
|
+
token = argv[idx]
|
|
194
|
+
if token == "--":
|
|
195
|
+
args.args = argv[idx + 1 :]
|
|
196
|
+
return args
|
|
197
|
+
if token == "-" or not token.startswith("-"):
|
|
198
|
+
if not code_found:
|
|
199
|
+
# This is the code (or the first arg when -f is used)
|
|
200
|
+
args.args = [token]
|
|
201
|
+
code_found = True
|
|
202
|
+
else:
|
|
203
|
+
args.args.append(token)
|
|
204
|
+
idx += 1
|
|
205
|
+
continue
|
|
206
|
+
if token in ("-h", "--help"):
|
|
207
|
+
args.help = True
|
|
208
|
+
return args
|
|
209
|
+
if token in ("-v", "--version"):
|
|
210
|
+
args.version = True
|
|
211
|
+
idx += 1
|
|
212
|
+
continue
|
|
213
|
+
if token in ("-a", "--awk"):
|
|
214
|
+
args.awk = True
|
|
215
|
+
idx += 1
|
|
216
|
+
continue
|
|
217
|
+
if token in ("-m", "--map"):
|
|
218
|
+
args.map = True
|
|
219
|
+
idx += 1
|
|
220
|
+
continue
|
|
221
|
+
if token in ("-P", "--no-print"):
|
|
222
|
+
args.no_print = True
|
|
223
|
+
idx += 1
|
|
224
|
+
continue
|
|
225
|
+
if token in ("-I", "--no-auto-import"):
|
|
226
|
+
args.no_auto_import = True
|
|
227
|
+
idx += 1
|
|
228
|
+
continue
|
|
229
|
+
if token == "--debug":
|
|
230
|
+
args.debug = True
|
|
231
|
+
idx += 1
|
|
232
|
+
continue
|
|
233
|
+
if token == "--debug-snail-ast":
|
|
234
|
+
args.debug_snail_ast = True
|
|
235
|
+
idx += 1
|
|
236
|
+
continue
|
|
237
|
+
if token == "-f":
|
|
238
|
+
if idx + 1 >= len(argv):
|
|
239
|
+
raise ValueError("option -f requires an argument")
|
|
240
|
+
args.file = argv[idx + 1]
|
|
241
|
+
code_found = True
|
|
242
|
+
idx += 2
|
|
243
|
+
continue
|
|
244
|
+
if token in ("-b", "--begin"):
|
|
245
|
+
if idx + 1 >= len(argv):
|
|
246
|
+
raise ValueError(f"option {token} requires an argument")
|
|
247
|
+
args.begin_code.append(argv[idx + 1])
|
|
248
|
+
idx += 2
|
|
249
|
+
continue
|
|
250
|
+
if token in ("-e", "--end"):
|
|
251
|
+
if idx + 1 >= len(argv):
|
|
252
|
+
raise ValueError(f"option {token} requires an argument")
|
|
253
|
+
args.end_code.append(argv[idx + 1])
|
|
254
|
+
idx += 2
|
|
255
|
+
continue
|
|
256
|
+
raise ValueError(f"unknown option: {token}")
|
|
257
|
+
return args
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _format_version(version: str, build_info: Optional[dict[str, object]]) -> str:
|
|
261
|
+
display_version = version if version.startswith("v") else f"v{version}"
|
|
262
|
+
if not build_info:
|
|
263
|
+
return display_version
|
|
264
|
+
git_rev = build_info.get("git_rev")
|
|
265
|
+
if not git_rev:
|
|
266
|
+
return display_version
|
|
267
|
+
|
|
268
|
+
suffixes: list[str] = []
|
|
269
|
+
if build_info.get("dirty"):
|
|
270
|
+
suffixes.append("!dirty")
|
|
271
|
+
if build_info.get("untagged"):
|
|
272
|
+
suffixes.append("!untagged")
|
|
273
|
+
|
|
274
|
+
if suffixes:
|
|
275
|
+
return f"{display_version} ({git_rev}) {' '.join(suffixes)}"
|
|
276
|
+
return f"{display_version} ({git_rev})"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _get_version() -> str:
|
|
280
|
+
from . import __version__ as version
|
|
281
|
+
|
|
282
|
+
return version
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
286
|
+
if argv is None:
|
|
287
|
+
_install_trimmed_excepthook()
|
|
288
|
+
argv = sys.argv[1:]
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
namespace = _parse_args(argv)
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
_print_help(file=sys.stderr)
|
|
294
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
|
|
297
|
+
if namespace.help:
|
|
298
|
+
_print_help()
|
|
299
|
+
return 0
|
|
300
|
+
if namespace.version:
|
|
301
|
+
print(_format_version(_get_version(), __build_info__))
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
# Validate --awk and --map are mutually exclusive
|
|
305
|
+
if namespace.awk and namespace.map:
|
|
306
|
+
print("error: --awk and --map cannot be used together", file=sys.stderr)
|
|
307
|
+
return 2
|
|
308
|
+
|
|
309
|
+
# Validate -b/--begin and -e/--end only with --awk or --map mode
|
|
310
|
+
if (namespace.begin_code or namespace.end_code) and not (namespace.awk or namespace.map):
|
|
311
|
+
print(
|
|
312
|
+
"error: -b/--begin and -e/--end options require --awk or --map mode",
|
|
313
|
+
file=sys.stderr,
|
|
314
|
+
)
|
|
315
|
+
return 2
|
|
316
|
+
|
|
317
|
+
mode = "map" if namespace.map else ("awk" if namespace.awk else "snail")
|
|
318
|
+
|
|
319
|
+
if namespace.file:
|
|
320
|
+
from pathlib import Path
|
|
321
|
+
|
|
322
|
+
path = Path(namespace.file)
|
|
323
|
+
try:
|
|
324
|
+
source = path.read_text()
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
print(f"failed to read {path}: {exc}", file=sys.stderr)
|
|
327
|
+
return 1
|
|
328
|
+
filename = str(path)
|
|
329
|
+
args = [filename, *namespace.args]
|
|
330
|
+
else:
|
|
331
|
+
if not namespace.args:
|
|
332
|
+
print("no input provided", file=sys.stderr)
|
|
333
|
+
return 1
|
|
334
|
+
source = namespace.args[0]
|
|
335
|
+
filename = "<cmd>"
|
|
336
|
+
args = ["--", *namespace.args[1:]]
|
|
337
|
+
|
|
338
|
+
if namespace.debug_snail_ast:
|
|
339
|
+
from . import parse_ast
|
|
340
|
+
|
|
341
|
+
snail_ast = parse_ast(
|
|
342
|
+
source,
|
|
343
|
+
mode=mode,
|
|
344
|
+
filename=filename,
|
|
345
|
+
begin_code=namespace.begin_code,
|
|
346
|
+
end_code=namespace.end_code,
|
|
347
|
+
)
|
|
348
|
+
print(snail_ast)
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
if namespace.debug:
|
|
352
|
+
import ast
|
|
353
|
+
import builtins
|
|
354
|
+
|
|
355
|
+
python_ast = compile_ast(
|
|
356
|
+
source,
|
|
357
|
+
mode=mode,
|
|
358
|
+
auto_print=not namespace.no_print,
|
|
359
|
+
filename=filename,
|
|
360
|
+
begin_code=namespace.begin_code,
|
|
361
|
+
end_code=namespace.end_code,
|
|
362
|
+
)
|
|
363
|
+
builtins.compile(python_ast, _display_filename(filename), "exec")
|
|
364
|
+
try:
|
|
365
|
+
output = ast.unparse(python_ast)
|
|
366
|
+
except AttributeError:
|
|
367
|
+
import astunparse
|
|
368
|
+
|
|
369
|
+
output = astunparse.unparse(python_ast).rstrip("\n")
|
|
370
|
+
print(output)
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
return exec(
|
|
374
|
+
source,
|
|
375
|
+
argv=args,
|
|
376
|
+
mode=mode,
|
|
377
|
+
auto_print=not namespace.no_print,
|
|
378
|
+
auto_import=not namespace.no_auto_import,
|
|
379
|
+
filename=filename,
|
|
380
|
+
begin_code=namespace.begin_code,
|
|
381
|
+
end_code=namespace.end_code,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
__all__ = ["install_helpers", "AutoImportDict", "AUTO_IMPORT_NAMES"]
|
|
7
|
+
|
|
8
|
+
# Names that can be auto-imported when first referenced.
|
|
9
|
+
# Maps name -> (module, attribute) where attribute is None for whole-module imports.
|
|
10
|
+
AUTO_IMPORT_NAMES: dict[str, tuple[str, Optional[str]]] = {
|
|
11
|
+
# Whole module imports: import X
|
|
12
|
+
"sys": ("sys", None),
|
|
13
|
+
"os": ("os", None),
|
|
14
|
+
# Attribute imports: from X import Y
|
|
15
|
+
"Path": ("pathlib", "Path"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AutoImportDict(dict):
|
|
20
|
+
"""A dict subclass that lazily imports allowed names on first access.
|
|
21
|
+
|
|
22
|
+
When a key lookup fails, if the key is in AUTO_IMPORT_NAMES,
|
|
23
|
+
the corresponding module/attribute is imported and stored in the dict.
|
|
24
|
+
Supports both whole-module imports (import sys) and attribute imports
|
|
25
|
+
(from pathlib import Path).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __missing__(self, key: str) -> object:
|
|
29
|
+
if key in AUTO_IMPORT_NAMES:
|
|
30
|
+
module_name, attr_name = AUTO_IMPORT_NAMES[key]
|
|
31
|
+
module = importlib.import_module(module_name)
|
|
32
|
+
value = getattr(module, attr_name) if attr_name else module
|
|
33
|
+
self[key] = value
|
|
34
|
+
return value
|
|
35
|
+
raise KeyError(key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_compact_try = None
|
|
39
|
+
_regex_search = None
|
|
40
|
+
_regex_compile = None
|
|
41
|
+
_subprocess_capture = None
|
|
42
|
+
_subprocess_status = None
|
|
43
|
+
_jmespath_query = None
|
|
44
|
+
_js = None
|
|
45
|
+
_lazy_text_class = None
|
|
46
|
+
_lazy_file_class = None
|
|
47
|
+
_incr_attr = None
|
|
48
|
+
_incr_index = None
|
|
49
|
+
_aug_attr = None
|
|
50
|
+
_aug_index = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_compact_try():
|
|
54
|
+
global _compact_try
|
|
55
|
+
if _compact_try is None:
|
|
56
|
+
from .compact_try import compact_try
|
|
57
|
+
|
|
58
|
+
_compact_try = compact_try
|
|
59
|
+
return _compact_try
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_regex_search():
|
|
63
|
+
global _regex_search
|
|
64
|
+
if _regex_search is None:
|
|
65
|
+
from .regex import regex_search
|
|
66
|
+
|
|
67
|
+
_regex_search = regex_search
|
|
68
|
+
return _regex_search
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_regex_compile():
|
|
72
|
+
global _regex_compile
|
|
73
|
+
if _regex_compile is None:
|
|
74
|
+
from .regex import regex_compile
|
|
75
|
+
|
|
76
|
+
_regex_compile = regex_compile
|
|
77
|
+
return _regex_compile
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_subprocess_capture():
|
|
81
|
+
global _subprocess_capture
|
|
82
|
+
if _subprocess_capture is None:
|
|
83
|
+
from .subprocess import SubprocessCapture
|
|
84
|
+
|
|
85
|
+
_subprocess_capture = SubprocessCapture
|
|
86
|
+
return _subprocess_capture
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_subprocess_status():
|
|
90
|
+
global _subprocess_status
|
|
91
|
+
if _subprocess_status is None:
|
|
92
|
+
from .subprocess import SubprocessStatus
|
|
93
|
+
|
|
94
|
+
_subprocess_status = SubprocessStatus
|
|
95
|
+
return _subprocess_status
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_jmespath_query():
|
|
99
|
+
global _jmespath_query
|
|
100
|
+
if _jmespath_query is None:
|
|
101
|
+
from .structured_accessor import __snail_jmespath_query
|
|
102
|
+
|
|
103
|
+
_jmespath_query = __snail_jmespath_query
|
|
104
|
+
return _jmespath_query
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_js():
|
|
108
|
+
global _js
|
|
109
|
+
if _js is None:
|
|
110
|
+
from .structured_accessor import js
|
|
111
|
+
|
|
112
|
+
_js = js
|
|
113
|
+
return _js
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_lazy_text_class():
|
|
117
|
+
global _lazy_text_class
|
|
118
|
+
if _lazy_text_class is None:
|
|
119
|
+
from .lazy_text import LazyText
|
|
120
|
+
|
|
121
|
+
_lazy_text_class = LazyText
|
|
122
|
+
return _lazy_text_class
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _get_lazy_file_class():
|
|
126
|
+
global _lazy_file_class
|
|
127
|
+
if _lazy_file_class is None:
|
|
128
|
+
from .lazy_file import LazyFile
|
|
129
|
+
|
|
130
|
+
_lazy_file_class = LazyFile
|
|
131
|
+
return _lazy_file_class
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _get_incr_attr():
|
|
135
|
+
global _incr_attr
|
|
136
|
+
if _incr_attr is None:
|
|
137
|
+
from .augmented import __snail_incr_attr
|
|
138
|
+
|
|
139
|
+
_incr_attr = __snail_incr_attr
|
|
140
|
+
return _incr_attr
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_incr_index():
|
|
144
|
+
global _incr_index
|
|
145
|
+
if _incr_index is None:
|
|
146
|
+
from .augmented import __snail_incr_index
|
|
147
|
+
|
|
148
|
+
_incr_index = __snail_incr_index
|
|
149
|
+
return _incr_index
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_aug_attr():
|
|
153
|
+
global _aug_attr
|
|
154
|
+
if _aug_attr is None:
|
|
155
|
+
from .augmented import __snail_aug_attr
|
|
156
|
+
|
|
157
|
+
_aug_attr = __snail_aug_attr
|
|
158
|
+
return _aug_attr
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_aug_index():
|
|
162
|
+
global _aug_index
|
|
163
|
+
if _aug_index is None:
|
|
164
|
+
from .augmented import __snail_aug_index
|
|
165
|
+
|
|
166
|
+
_aug_index = __snail_aug_index
|
|
167
|
+
return _aug_index
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _lazy_compact_try(expr_fn, fallback_fn=None):
|
|
171
|
+
return _get_compact_try()(expr_fn, fallback_fn)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _lazy_regex_search(value, pattern):
|
|
175
|
+
return _get_regex_search()(value, pattern)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _lazy_regex_compile(pattern):
|
|
179
|
+
return _get_regex_compile()(pattern)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _lazy_subprocess_capture(cmd: str):
|
|
183
|
+
return _get_subprocess_capture()(cmd)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _lazy_subprocess_status(cmd: str):
|
|
187
|
+
return _get_subprocess_status()(cmd)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _lazy_jmespath_query(query: str):
|
|
191
|
+
return _get_jmespath_query()(query)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _lazy_js(input_data=None):
|
|
195
|
+
return _get_js()(input_data)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _lazy_incr_attr(obj, attr: str, delta: int, pre: bool):
|
|
199
|
+
return _get_incr_attr()(obj, attr, delta, pre)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _lazy_incr_index(obj, index, delta: int, pre: bool):
|
|
203
|
+
return _get_incr_index()(obj, index, delta, pre)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _lazy_aug_attr(obj, attr: str, value, op: str):
|
|
207
|
+
return _get_aug_attr()(obj, attr, value, op)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _lazy_aug_index(obj, index, value, op: str):
|
|
211
|
+
return _get_aug_index()(obj, index, value, op)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def __snail_partial(func, /, *args, **kwargs):
|
|
215
|
+
import functools
|
|
216
|
+
|
|
217
|
+
return functools.partial(func, *args, **kwargs)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def __snail_contains__(left, right):
|
|
221
|
+
method = getattr(right, "__snail_contains__", None)
|
|
222
|
+
if method is not None:
|
|
223
|
+
return method(left)
|
|
224
|
+
return left in right
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def __snail_contains_not__(left, right):
|
|
228
|
+
method = getattr(right, "__snail_contains__", None)
|
|
229
|
+
if method is not None:
|
|
230
|
+
return not bool(method(left))
|
|
231
|
+
return left not in right
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def install_helpers(globals_dict: dict) -> None:
|
|
235
|
+
globals_dict["__snail_compact_try"] = _lazy_compact_try
|
|
236
|
+
globals_dict["__snail_regex_search"] = _lazy_regex_search
|
|
237
|
+
globals_dict["__snail_regex_compile"] = _lazy_regex_compile
|
|
238
|
+
globals_dict["__SnailSubprocessCapture"] = _lazy_subprocess_capture
|
|
239
|
+
globals_dict["__SnailSubprocessStatus"] = _lazy_subprocess_status
|
|
240
|
+
globals_dict["__snail_jmespath_query"] = _lazy_jmespath_query
|
|
241
|
+
globals_dict["__snail_partial"] = __snail_partial
|
|
242
|
+
globals_dict["__snail_contains__"] = __snail_contains__
|
|
243
|
+
globals_dict["__snail_contains_not__"] = __snail_contains_not__
|
|
244
|
+
globals_dict["__snail_incr_attr"] = _lazy_incr_attr
|
|
245
|
+
globals_dict["__snail_incr_index"] = _lazy_incr_index
|
|
246
|
+
globals_dict["__snail_aug_attr"] = _lazy_aug_attr
|
|
247
|
+
globals_dict["__snail_aug_index"] = _lazy_aug_index
|
|
248
|
+
globals_dict["js"] = _lazy_js
|
|
249
|
+
globals_dict["__SnailLazyText"] = _get_lazy_text_class()
|
|
250
|
+
globals_dict["__SnailLazyFile"] = _get_lazy_file_class()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import operator as _op
|
|
4
|
+
|
|
5
|
+
_OPS = {
|
|
6
|
+
"+": _op.add,
|
|
7
|
+
"-": _op.sub,
|
|
8
|
+
"*": _op.mul,
|
|
9
|
+
"/": _op.truediv,
|
|
10
|
+
"//": _op.floordiv,
|
|
11
|
+
"%": _op.mod,
|
|
12
|
+
"**": _op.pow,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _apply_op(left, right, op: str):
|
|
17
|
+
try:
|
|
18
|
+
func = _OPS[op]
|
|
19
|
+
except KeyError as exc:
|
|
20
|
+
raise ValueError(f"unknown augmented op: {op}") from exc
|
|
21
|
+
return func(left, right)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __snail_incr_attr(obj, attr: str, delta: int, pre: bool):
|
|
25
|
+
old = getattr(obj, attr)
|
|
26
|
+
new = old + delta
|
|
27
|
+
setattr(obj, attr, new)
|
|
28
|
+
return new if pre else old
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __snail_incr_index(obj, index, delta: int, pre: bool):
|
|
32
|
+
old = obj[index]
|
|
33
|
+
new = old + delta
|
|
34
|
+
obj[index] = new
|
|
35
|
+
return new if pre else old
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __snail_aug_attr(obj, attr: str, value, op: str):
|
|
39
|
+
old = getattr(obj, attr)
|
|
40
|
+
new = _apply_op(old, value, op)
|
|
41
|
+
setattr(obj, attr, new)
|
|
42
|
+
return new
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __snail_aug_index(obj, index, value, op: str):
|
|
46
|
+
old = obj[index]
|
|
47
|
+
new = _apply_op(old, value, op)
|
|
48
|
+
obj[index] = new
|
|
49
|
+
return new
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def compact_try(expr_fn, fallback_fn=None):
|
|
5
|
+
try:
|
|
6
|
+
return expr_fn()
|
|
7
|
+
except Exception as exc:
|
|
8
|
+
if fallback_fn is None:
|
|
9
|
+
fallback_member = getattr(exc, "__fallback__", None)
|
|
10
|
+
if callable(fallback_member):
|
|
11
|
+
return fallback_member()
|
|
12
|
+
return None
|
|
13
|
+
return fallback_fn(exc)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Lazy file opener for map mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LazyFile:
|
|
7
|
+
"""Context manager that opens the file on first access."""
|
|
8
|
+
|
|
9
|
+
__slots__ = ("_path", "_mode", "_kwargs", "_fd", "_closed")
|
|
10
|
+
|
|
11
|
+
def __init__(self, path, mode="r", **kwargs):
|
|
12
|
+
self._path = path
|
|
13
|
+
self._mode = mode
|
|
14
|
+
self._kwargs = kwargs
|
|
15
|
+
self._fd = None
|
|
16
|
+
self._closed = False
|
|
17
|
+
|
|
18
|
+
def _ensure_open(self):
|
|
19
|
+
if self._closed:
|
|
20
|
+
raise ValueError("I/O operation on closed file.")
|
|
21
|
+
if self._fd is None:
|
|
22
|
+
self._fd = open(self._path, self._mode, **self._kwargs)
|
|
23
|
+
return self._fd
|
|
24
|
+
|
|
25
|
+
def __enter__(self):
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, exc_type, exc, tb):
|
|
29
|
+
self._closed = True
|
|
30
|
+
if self._fd is not None:
|
|
31
|
+
self._fd.close()
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def __getattr__(self, name):
|
|
35
|
+
return getattr(self._ensure_open(), name)
|
|
36
|
+
|
|
37
|
+
def __iter__(self):
|
|
38
|
+
return iter(self._ensure_open())
|
|
39
|
+
|
|
40
|
+
def __next__(self):
|
|
41
|
+
return next(self._ensure_open())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Lazy text content reader for map mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LazyText:
|
|
7
|
+
"""Lazily reads file content on first access."""
|
|
8
|
+
|
|
9
|
+
__slots__ = ("_fd", "_text")
|
|
10
|
+
|
|
11
|
+
def __init__(self, fd):
|
|
12
|
+
self._fd = fd
|
|
13
|
+
self._text = None
|
|
14
|
+
|
|
15
|
+
def _ensure_loaded(self):
|
|
16
|
+
if self._text is None:
|
|
17
|
+
self._text = self._fd.read()
|
|
18
|
+
return self._text
|
|
19
|
+
|
|
20
|
+
def __str__(self):
|
|
21
|
+
return self._ensure_loaded()
|
|
22
|
+
|
|
23
|
+
def __repr__(self):
|
|
24
|
+
return repr(str(self))
|
|
25
|
+
|
|
26
|
+
def __eq__(self, other):
|
|
27
|
+
if isinstance(other, LazyText):
|
|
28
|
+
return str(self) == str(other)
|
|
29
|
+
return str(self) == other
|
|
30
|
+
|
|
31
|
+
def __hash__(self):
|
|
32
|
+
return hash(str(self))
|
|
33
|
+
|
|
34
|
+
def __len__(self):
|
|
35
|
+
return len(str(self))
|
|
36
|
+
|
|
37
|
+
def __iter__(self):
|
|
38
|
+
return iter(str(self))
|
|
39
|
+
|
|
40
|
+
def __contains__(self, item):
|
|
41
|
+
return item in str(self)
|
|
42
|
+
|
|
43
|
+
def __add__(self, other):
|
|
44
|
+
return str(self) + other
|
|
45
|
+
|
|
46
|
+
def __radd__(self, other):
|
|
47
|
+
return other + str(self)
|
|
48
|
+
|
|
49
|
+
def __getattr__(self, name):
|
|
50
|
+
return getattr(str(self), name)
|
snail/runtime/regex.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SnailRegex:
|
|
7
|
+
def __init__(self, pattern: str) -> None:
|
|
8
|
+
self.pattern = pattern
|
|
9
|
+
self._regex = re.compile(pattern)
|
|
10
|
+
|
|
11
|
+
def search(self, value):
|
|
12
|
+
return regex_search(value, self._regex)
|
|
13
|
+
|
|
14
|
+
def __contains__(self, value):
|
|
15
|
+
return bool(self.__snail_contains__(value))
|
|
16
|
+
|
|
17
|
+
def __snail_contains__(self, value):
|
|
18
|
+
return self.search(value)
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"/{self.pattern}/"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def regex_search(value, pattern):
|
|
25
|
+
if isinstance(pattern, SnailRegex):
|
|
26
|
+
return pattern.search(value)
|
|
27
|
+
if hasattr(pattern, "search"):
|
|
28
|
+
match = pattern.search(value)
|
|
29
|
+
else:
|
|
30
|
+
match = re.search(pattern, value)
|
|
31
|
+
if match is None:
|
|
32
|
+
return ()
|
|
33
|
+
return (match.group(0),) + match.groups()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def regex_compile(pattern):
|
|
37
|
+
return SnailRegex(pattern)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import os as _os
|
|
5
|
+
import sys as _sys
|
|
6
|
+
|
|
7
|
+
def __snail_jmespath_query(query: str):
|
|
8
|
+
"""Create a callable that applies JMESPath query.
|
|
9
|
+
|
|
10
|
+
Used by the $[query] syntax which lowers to __snail_jmespath_query(query).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import jmespath as _jmespath
|
|
14
|
+
|
|
15
|
+
def apply(data):
|
|
16
|
+
return _jmespath.search(query, data)
|
|
17
|
+
|
|
18
|
+
return apply
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_jsonl(content: str):
|
|
22
|
+
lines = [line for line in content.splitlines() if line.strip()]
|
|
23
|
+
if not lines:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
items = []
|
|
27
|
+
for line in lines:
|
|
28
|
+
try:
|
|
29
|
+
items.append(_json.loads(line))
|
|
30
|
+
except _json.JSONDecodeError as exc:
|
|
31
|
+
raise _json.JSONDecodeError(
|
|
32
|
+
f"Invalid JSONL line: {exc.msg}",
|
|
33
|
+
line,
|
|
34
|
+
exc.pos,
|
|
35
|
+
) from exc
|
|
36
|
+
return items
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def js(input_data=None):
|
|
40
|
+
"""Parse JSON from various input sources.
|
|
41
|
+
|
|
42
|
+
Returns the parsed Python object (dict, list, etc.) directly.
|
|
43
|
+
If called with no arguments, reads from stdin.
|
|
44
|
+
"""
|
|
45
|
+
if input_data is None:
|
|
46
|
+
input_data = _sys.stdin
|
|
47
|
+
|
|
48
|
+
if isinstance(input_data, str):
|
|
49
|
+
try:
|
|
50
|
+
return _json.loads(input_data)
|
|
51
|
+
except _json.JSONDecodeError:
|
|
52
|
+
if _os.path.exists(input_data):
|
|
53
|
+
with open(input_data, "r", encoding="utf-8") as handle:
|
|
54
|
+
content = handle.read()
|
|
55
|
+
try:
|
|
56
|
+
return _json.loads(content)
|
|
57
|
+
except _json.JSONDecodeError:
|
|
58
|
+
return _parse_jsonl(content)
|
|
59
|
+
else:
|
|
60
|
+
return _parse_jsonl(input_data)
|
|
61
|
+
elif hasattr(input_data, "read"):
|
|
62
|
+
content = input_data.read()
|
|
63
|
+
if isinstance(content, bytes):
|
|
64
|
+
content = content.decode("utf-8")
|
|
65
|
+
try:
|
|
66
|
+
return _json.loads(content)
|
|
67
|
+
except _json.JSONDecodeError:
|
|
68
|
+
return _parse_jsonl(content)
|
|
69
|
+
elif isinstance(input_data, (dict, list, int, float, bool)) or input_data is None:
|
|
70
|
+
return input_data
|
|
71
|
+
else:
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"js() input must be JSON-compatible, got {type(input_data).__name__}"
|
|
74
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SubprocessCapture:
|
|
7
|
+
def __init__(self, cmd: str) -> None:
|
|
8
|
+
self.cmd = cmd
|
|
9
|
+
|
|
10
|
+
def __call__(self, input_data=None):
|
|
11
|
+
try:
|
|
12
|
+
if input_data is None:
|
|
13
|
+
completed = subprocess.run(
|
|
14
|
+
self.cmd,
|
|
15
|
+
shell=True,
|
|
16
|
+
check=True,
|
|
17
|
+
text=True,
|
|
18
|
+
stdout=subprocess.PIPE,
|
|
19
|
+
)
|
|
20
|
+
else:
|
|
21
|
+
if not isinstance(input_data, (str, bytes)):
|
|
22
|
+
input_data = str(input_data)
|
|
23
|
+
completed = subprocess.run(
|
|
24
|
+
self.cmd,
|
|
25
|
+
shell=True,
|
|
26
|
+
check=True,
|
|
27
|
+
text=True,
|
|
28
|
+
input=input_data,
|
|
29
|
+
stdout=subprocess.PIPE,
|
|
30
|
+
)
|
|
31
|
+
return completed.stdout.rstrip("\n")
|
|
32
|
+
except subprocess.CalledProcessError as exc:
|
|
33
|
+
|
|
34
|
+
def __fallback(exc=exc):
|
|
35
|
+
raise exc
|
|
36
|
+
|
|
37
|
+
exc.__fallback__ = __fallback
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SubprocessStatus:
|
|
42
|
+
def __init__(self, cmd: str) -> None:
|
|
43
|
+
self.cmd = cmd
|
|
44
|
+
|
|
45
|
+
def __call__(self, input_data=None):
|
|
46
|
+
try:
|
|
47
|
+
if input_data is None:
|
|
48
|
+
subprocess.run(self.cmd, shell=True, check=True)
|
|
49
|
+
else:
|
|
50
|
+
if not isinstance(input_data, (str, bytes)):
|
|
51
|
+
input_data = str(input_data)
|
|
52
|
+
subprocess.run(
|
|
53
|
+
self.cmd,
|
|
54
|
+
shell=True,
|
|
55
|
+
check=True,
|
|
56
|
+
text=True,
|
|
57
|
+
input=input_data,
|
|
58
|
+
)
|
|
59
|
+
return 0
|
|
60
|
+
except subprocess.CalledProcessError as exc:
|
|
61
|
+
|
|
62
|
+
def __fallback(exc=exc):
|
|
63
|
+
return exc.returncode
|
|
64
|
+
|
|
65
|
+
exc.__fallback__ = __fallback
|
|
66
|
+
raise
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snail-lang
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Requires-Dist: astunparse>=1.6.3 ; python_full_version < '3.9'
|
|
5
|
+
Requires-Dist: jmespath>=1.0.1
|
|
6
|
+
Requires-Dist: maturin>=1.5 ; extra == 'dev'
|
|
7
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Summary: Snail programming language interpreter
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="logo.png" alt="Snail logo" width="200">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<h1 align="center">Snail</h1>
|
|
19
|
+
|
|
20
|
+
**Snail** is a programming language that compiles to Python, combining Python's familiarity and extensive libraries with Perl/awk-inspired syntax for quick scripts and one-liners. Its what you get when you shove a snake in a shell.
|
|
21
|
+
|
|
22
|
+
## AI Slop!
|
|
23
|
+
|
|
24
|
+
Snail is me learning how to devlop code using LLMs. I think its neat, and
|
|
25
|
+
maybe useful. I don't think this is high quality. I am going to try and LLM my
|
|
26
|
+
way into something good, but its certainly not there yet.
|
|
27
|
+
|
|
28
|
+
## Installing Snail
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install snail-lang
|
|
32
|
+
-or-
|
|
33
|
+
uv tool install snail-lang
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That installs the `snail` CLI for your user; try it with `snail "print('hello')"` once the install completes.
|
|
37
|
+
|
|
38
|
+
## ✨ What Makes Snail Unique
|
|
39
|
+
|
|
40
|
+
### Curly Braces, Not Indentation
|
|
41
|
+
|
|
42
|
+
Write Python logic without worrying about whitespace:
|
|
43
|
+
|
|
44
|
+
```snail
|
|
45
|
+
def process(items) {
|
|
46
|
+
for item in items {
|
|
47
|
+
if item > 0 { print(item) }
|
|
48
|
+
else { continue }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Note, since it is jarring to write python with semicolons everywhere,
|
|
54
|
+
semicolons are optional. You can separate statements with newlines.
|
|
55
|
+
|
|
56
|
+
### Awk Mode
|
|
57
|
+
|
|
58
|
+
Process files line-by-line with familiar awk semantics:
|
|
59
|
+
|
|
60
|
+
```snail-awk("hello world\nfoo bar\n")
|
|
61
|
+
BEGIN { print("start") }
|
|
62
|
+
/hello/ { print("matched:", $0) }
|
|
63
|
+
{ print($1, "->", $2) }
|
|
64
|
+
END { print("done") }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Built-in variables:**
|
|
68
|
+
|
|
69
|
+
| Variable | Description |
|
|
70
|
+
|----------|-------------|
|
|
71
|
+
| `$0` | Current line (with newline stripped) |
|
|
72
|
+
| `$1`, `$2`, ... | Individual fields (whitespace-split) |
|
|
73
|
+
| `$f` | All fields as a list |
|
|
74
|
+
| `$n` | Global line number (across all files) |
|
|
75
|
+
| `$fn` | Per-file line number |
|
|
76
|
+
| `$p` | Current file path |
|
|
77
|
+
| `$m` | Last regex match object |
|
|
78
|
+
|
|
79
|
+
Begin/end blocks can live in the source file (`BEGIN { ... }` / `END { ... }`) or be supplied
|
|
80
|
+
via CLI flags (`-b`/`--begin`, `-e`/`--end`) for setup and teardown. CLI BEGIN blocks run
|
|
81
|
+
before in-file BEGIN blocks; CLI END blocks run after in-file END blocks.
|
|
82
|
+
`BEGIN` and `END` are reserved keywords in all modes.
|
|
83
|
+
BEGIN/END blocks are regular Snail blocks, so awk/map-only `$` variables are not available inside them.
|
|
84
|
+
```bash
|
|
85
|
+
echo -e "5\n4\n3\n2\n1" | snail --awk --begin 'total = 0' --end 'print("Sum:", total)' '/^[0-9]+/ { total = total + int($1) }'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Map Mode
|
|
89
|
+
|
|
90
|
+
Process files one at a time instead of line-by-line:
|
|
91
|
+
|
|
92
|
+
```snail-map
|
|
93
|
+
BEGIN { print("start") }
|
|
94
|
+
print("File:", $src)
|
|
95
|
+
print("Size:", len($text), "bytes")
|
|
96
|
+
END { print("done") }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Built-in variables:**
|
|
100
|
+
|
|
101
|
+
| Variable | Description |
|
|
102
|
+
|----------|-------------|
|
|
103
|
+
| `$src` | Current file path |
|
|
104
|
+
| `$fd` | Open file handle for the current file |
|
|
105
|
+
| `$text` | Lazy text view of the current file contents |
|
|
106
|
+
|
|
107
|
+
Begin/end blocks can live in the source file (`BEGIN { ... }` / `END { ... }`) or be supplied
|
|
108
|
+
via CLI flags (`-b`/`--begin`, `-e`/`--end`) for setup and teardown. CLI BEGIN blocks run
|
|
109
|
+
before in-file BEGIN blocks; CLI END blocks run after in-file END blocks.
|
|
110
|
+
BEGIN/END blocks are regular Snail blocks, so awk/map-only `$` variables are not available inside them.
|
|
111
|
+
`BEGIN` and `END` are reserved keywords in all modes.
|
|
112
|
+
```bash
|
|
113
|
+
snail --map --begin "print('start')" --end "print('done')" "print($src)" *.txt
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Compact Error Handling
|
|
117
|
+
|
|
118
|
+
The `?` operator makes error handling terse yet expressive:
|
|
119
|
+
|
|
120
|
+
```snail
|
|
121
|
+
# Swallow exception, return None
|
|
122
|
+
err = risky()?
|
|
123
|
+
|
|
124
|
+
# Swallow exception, return exception object
|
|
125
|
+
err = risky():$e?
|
|
126
|
+
|
|
127
|
+
# Provide a fallback value (exception available as $e)
|
|
128
|
+
value = js("malformed json"):%{"error": "invalid json"}?
|
|
129
|
+
details = fetch_url("foo.com"):"default html"?
|
|
130
|
+
exception_info = fetch_url("example.com"):$e.http_response_code?
|
|
131
|
+
|
|
132
|
+
# Access attributes directly
|
|
133
|
+
name = risky("")?.__class__.__name__
|
|
134
|
+
args = risky("becomes a list"):[1,2,3]?[0]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Destructuring + `if let` / `while let`
|
|
138
|
+
|
|
139
|
+
Unpack tuples and lists directly, including Python-style rest bindings:
|
|
140
|
+
|
|
141
|
+
```snail
|
|
142
|
+
x, *xs = [1, 2, 3]
|
|
143
|
+
|
|
144
|
+
if let [head, *tail] = [1, 2, 3]; head > 0 {
|
|
145
|
+
print(head, tail)
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`if let`/`while let` only enter the block when the destructuring succeeds. A guard
|
|
150
|
+
after `;` lets you add a boolean check that runs after the bindings are created.
|
|
151
|
+
|
|
152
|
+
Note that this syntax is more powerful than the walrus operator as that does
|
|
153
|
+
not allow for destructuring.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
### Pipeline Operator
|
|
157
|
+
|
|
158
|
+
The `|` operator enables data pipelining as syntactic sugar for nested
|
|
159
|
+
function calls. `x | y | z` becomes `z(y(x))`. This lets you stay in a
|
|
160
|
+
shell mindset.
|
|
161
|
+
|
|
162
|
+
```snail
|
|
163
|
+
# Pipe data to subprocess stdin
|
|
164
|
+
result = "hello\nworld" | $(grep hello)
|
|
165
|
+
|
|
166
|
+
# Chain multiple transformations
|
|
167
|
+
output = "foo\nbar" | $(grep foo) | $(wc -l)
|
|
168
|
+
|
|
169
|
+
# Custom pipeline handlers
|
|
170
|
+
class Doubler {
|
|
171
|
+
def __call__(self, x) { return x * 2 }
|
|
172
|
+
}
|
|
173
|
+
doubled = 21 | Doubler() # yields 42
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Arbitrary callables make up pipelines, even if they have multiple parameters.
|
|
177
|
+
Snail supports this via placeholders.
|
|
178
|
+
```snail
|
|
179
|
+
greeting = "World" | greet("Hello ", _) # greet("Hello ", "World")
|
|
180
|
+
excited = "World" | greet(_, "!") # greet("World", "!")
|
|
181
|
+
formal = "World" | greet("Hello ", suffix=_) # greet("Hello ", "World")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
When a pipeline targets a call expression, the left-hand value is passed to the
|
|
185
|
+
resulting callable. If the call includes a single `_` placeholder, Snail substitutes
|
|
186
|
+
the piped value at that position (including keyword arguments). Only one
|
|
187
|
+
placeholder is allowed in a piped call. Outside of pipeline calls, `_` remains a
|
|
188
|
+
normal identifier.
|
|
189
|
+
|
|
190
|
+
### Built-in Subprocess
|
|
191
|
+
|
|
192
|
+
Shell commands are first-class citizens with capturing and non-capturing
|
|
193
|
+
forms.
|
|
194
|
+
|
|
195
|
+
```snail
|
|
196
|
+
# Capture command output with interpolation
|
|
197
|
+
greeting = $(echo hello {name})
|
|
198
|
+
|
|
199
|
+
# Pipe data through commands
|
|
200
|
+
result = "foo\nbar\nbaz" | $(grep bar) | $(cat -n)
|
|
201
|
+
|
|
202
|
+
# Check command status
|
|
203
|
+
@(make build)? # returns exit code on failure instead of raising
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
### Regex Literals
|
|
208
|
+
|
|
209
|
+
Snail supports first class patterns. Think of them as an infinte set.
|
|
210
|
+
|
|
211
|
+
```snail
|
|
212
|
+
if bad_email in /^[\w.]+@[\w.]+$/ {
|
|
213
|
+
print("Valid email")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Compiled regex for reuse
|
|
217
|
+
pattern = /\d{3}-\d{4}/
|
|
218
|
+
match = pattern.search(phone)
|
|
219
|
+
match2 = "555-1212" in pattern
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Snail regexes don't return a match object, rather they return a tuple
|
|
223
|
+
containing all of the match groups, including group 0. Both `search` and `in`
|
|
224
|
+
return the same tuple (or `()` when there is no match).
|
|
225
|
+
|
|
226
|
+
### JSON Queries with JMESPath
|
|
227
|
+
|
|
228
|
+
Parse and query JSON data with the `js()` function and structured pipeline accessor:
|
|
229
|
+
|
|
230
|
+
```snail
|
|
231
|
+
# Parse JSON and query with $[jmespath]
|
|
232
|
+
|
|
233
|
+
# JSON query with JMESPath
|
|
234
|
+
data = js($(curl -s https://api.github.com/repos/sudonym1/snail))
|
|
235
|
+
counts = data | $[stargazers_count]
|
|
236
|
+
|
|
237
|
+
# Inline parsing and querying
|
|
238
|
+
result = js('{{"foo": 12}}') | $[foo]
|
|
239
|
+
|
|
240
|
+
# JSONL parsing returns a list
|
|
241
|
+
names = js('{{"name": "Ada"}}\n{{"name": "Lin"}}') | $[[*].name]
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Full Python Interoperability
|
|
245
|
+
|
|
246
|
+
Snail compiles to Python AST—import any Python module, use any library, in any
|
|
247
|
+
environment. Assuming that you are using Python 3.8 or later.
|
|
248
|
+
|
|
249
|
+
## 🚀 Quick Start
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
# One-liner: arithmetic + interpolation
|
|
253
|
+
snail 'name="Snail"; print("{name} says: {6 * 7}")'
|
|
254
|
+
|
|
255
|
+
# JSON query with JMESPath
|
|
256
|
+
snail 'js($(curl -s https://api.github.com/repos/sudonym1/snail)) | $[stargazers_count]'
|
|
257
|
+
|
|
258
|
+
# Compact error handling with fallback
|
|
259
|
+
snail 'result = int("oops"):"bad int {$e}"?; print(result)'
|
|
260
|
+
|
|
261
|
+
# Regex match and capture
|
|
262
|
+
snail 'if let [_, user, domain] = "user@example.com" in /^[\w.]+@([\w.]+)$/ { print(domain) }'
|
|
263
|
+
|
|
264
|
+
# Awk mode: print line numbers for matches
|
|
265
|
+
rg -n "TODO" README.md | snail --awk '/TODO/ { print("{$n}: {$0}") }'
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## 📚 Documentation
|
|
269
|
+
|
|
270
|
+
Documentation is WIP
|
|
271
|
+
|
|
272
|
+
- **[Language Reference](docs/REFERENCE.md)** — Complete syntax and semantics
|
|
273
|
+
- **[examples/all_syntax.snail](examples/all_syntax.snail)** — Every feature in one file
|
|
274
|
+
- **[examples/awk.snail](examples/awk.snail)** — Awk mode examples
|
|
275
|
+
- **[examples/map.snail](examples/map.snail)** — Map mode examples
|
|
276
|
+
|
|
277
|
+
## 🔌 Editor Support
|
|
278
|
+
|
|
279
|
+
Vim/Neovim plugin with syntax highlighting, formatting, and run commands:
|
|
280
|
+
|
|
281
|
+
```vim
|
|
282
|
+
Plug 'sudonym1/snail', { 'rtp': 'extras/vim' }
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
See [extras/vim/README.md](extras/vim/README.md) for details. Tree-sitter grammar available in `extras/tree-sitter-snail/`.
|
|
286
|
+
|
|
287
|
+
## Performance
|
|
288
|
+
|
|
289
|
+
Section is WIP
|
|
290
|
+
|
|
291
|
+
Startup performance is benchmarked with `./benchmarks/startup.py`. On my
|
|
292
|
+
machine snail adds 5 ms of overhead above the regular python3 interpreter.
|
|
293
|
+
|
|
294
|
+
## 🛠️ Building from Source
|
|
295
|
+
|
|
296
|
+
### Prerequisites
|
|
297
|
+
|
|
298
|
+
**Python 3.8+** (required at runtime)
|
|
299
|
+
|
|
300
|
+
Snail runs in-process via a Pyo3 extension module, so it uses the active Python environment.
|
|
301
|
+
|
|
302
|
+
Installation per platform:
|
|
303
|
+
- **Ubuntu/Debian**: `sudo apt install python3 python3-dev`
|
|
304
|
+
- **Fedora/RHEL**: `sudo dnf install python3 python3-devel`
|
|
305
|
+
- **macOS**: `brew install python@3.12` (or use the system Python 3)
|
|
306
|
+
- **Windows**: Download from [python.org](https://www.python.org/downloads/)
|
|
307
|
+
|
|
308
|
+
### Build, Test, and Install
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
# Clone the repository
|
|
312
|
+
git clone https://github.com/sudonym1/snail.git
|
|
313
|
+
cd snail
|
|
314
|
+
|
|
315
|
+
make test
|
|
316
|
+
make install
|
|
317
|
+
```
|
|
318
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
snail\__init__.py,sha256=ycv9SAHU4p6ndYoMuBL022C3Nv4O9gPPH16cGN_mMqM,821
|
|
2
|
+
snail\_native.pyd,sha256=MaXUH30xxyYSiSt0w6qqZjuCRS72zABnqES-Dvtkt3s,2135552
|
|
3
|
+
snail\cli.py,sha256=k9u3iVwZCH1KLYYBqF9geq3QNsG7CBkzelBaDr2xqBQ,12184
|
|
4
|
+
snail\runtime\__init__.py,sha256=PvekYIXdfs8xT3fi_ZEunarstfgc4AF6b9SqbJfwsDE,6766
|
|
5
|
+
snail\runtime\augmented.py,sha256=ZBlgJL1v-uRxPOiB2QToshx6vreousSTgT3rbUiPyQ0,1091
|
|
6
|
+
snail\runtime\compact_try.py,sha256=0nxfcAOsISMo3RsVb3_9kYemXtFLfW3kHrQSjAdkJxE,393
|
|
7
|
+
snail\runtime\lazy_file.py,sha256=mYMQdTv9c3_g_0OzjqczBZcmiX7DgTd1yY7YS9cmPsY,1100
|
|
8
|
+
snail\runtime\lazy_text.py,sha256=xNXY0r5VjK-BLRQQOXj3EmGzzjBBnPPJZZfMUkhlqx4,1143
|
|
9
|
+
snail\runtime\regex.py,sha256=Vc1LJe6OfCh0G8z_n83rGz8tqrjyESg4nbbwFrR2ARw,916
|
|
10
|
+
snail\runtime\structured_accessor.py,sha256=qSXauwYFZ9Ig_ACsxKarU3O49OGxbyhvm8LhEPRyT70,2237
|
|
11
|
+
snail\runtime\subprocess.py,sha256=ILDdfpqRXMFjWKYdpxQvEpie_31kItCuDO2WlsNpnks,1955
|
|
12
|
+
snail_lang-0.7.1.dist-info\METADATA,sha256=tdjZgk_uEjI6gbQvdsA3Ls570bRyOFzsvmuboCkfn60,9645
|
|
13
|
+
snail_lang-0.7.1.dist-info\WHEEL,sha256=gPqN4EsdiAyGvmfrYy_ONrF276O8o0hPitI2CKZrEFA,95
|
|
14
|
+
snail_lang-0.7.1.dist-info\entry_points.txt,sha256=8u6pjHZtKGdaZQlr6yKbftplObC07caDZ44j5PIPU6M,39
|
|
15
|
+
snail_lang-0.7.1.dist-info\licenses\LICENSE,sha256=iT_e39WExFFehp3Zh4faVe3dEv3BrhhX_v3obKdBM9M,1082
|
|
16
|
+
snail_lang-0.7.1.dist-info\RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Seamus Connor
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
20
|
+
|