pysipl 0.1.0__tar.gz
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.
- pysipl-0.1.0/.gitignore +11 -0
- pysipl-0.1.0/PKG-INFO +13 -0
- pysipl-0.1.0/examples/factorial.sipl +8 -0
- pysipl-0.1.0/examples/gcd.sipl +8 -0
- pysipl-0.1.0/examples/invalid/if_without_else.sipl +4 -0
- pysipl-0.1.0/examples/invalid/missing_end.sipl +3 -0
- pysipl-0.1.0/examples/invalid/trailing_semicolon.sipl +3 -0
- pysipl-0.1.0/examples/power.sipl +8 -0
- pysipl-0.1.0/examples/sign.sipl +9 -0
- pysipl-0.1.0/pyproject.toml +40 -0
- pysipl-0.1.0/src/sipl/__init__.py +3 -0
- pysipl-0.1.0/src/sipl/ast.py +121 -0
- pysipl-0.1.0/src/sipl/cli.py +213 -0
- pysipl-0.1.0/src/sipl/dap.py +259 -0
- pysipl-0.1.0/src/sipl/diagnostics.py +36 -0
- pysipl-0.1.0/src/sipl/grammar/sipl.lark +61 -0
- pysipl-0.1.0/src/sipl/interpreter/__init__.py +39 -0
- pysipl-0.1.0/src/sipl/interpreter/engine.py +200 -0
- pysipl-0.1.0/src/sipl/interpreter/events.py +49 -0
- pysipl-0.1.0/src/sipl/parser.py +185 -0
- pysipl-0.1.0/tests/test_cli.py +193 -0
- pysipl-0.1.0/tests/test_dap.py +288 -0
- pysipl-0.1.0/tests/test_interpreter.py +222 -0
- pysipl-0.1.0/tests/test_parser.py +227 -0
pysipl-0.1.0/.gitignore
ADDED
pysipl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pysipl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parser, interpreter, trace and debug adapter for the SIPL teaching language
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: lark<2,>=1.2
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: build; extra == 'dev'
|
|
10
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pysipl"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Parser, interpreter, trace and debug adapter for the SIPL teaching language"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["lark>=1.2,<2"]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = ["pytest", "pytest-cov", "ruff", "mypy", "build"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
sipl = "sipl.cli:main"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.version]
|
|
20
|
+
path = "src/sipl/__init__.py"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/sipl"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.coverage.run]
|
|
29
|
+
source = ["sipl"]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 100
|
|
33
|
+
src = ["src", "tests"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
37
|
+
|
|
38
|
+
[tool.mypy]
|
|
39
|
+
strict = true
|
|
40
|
+
files = ["src"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Typed AST of SIPL. Every node knows its position in the source text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields, is_dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class SourceSpan:
|
|
11
|
+
"""Source range: lines and columns are 1-based, the end column is exclusive."""
|
|
12
|
+
|
|
13
|
+
start_line: int
|
|
14
|
+
start_column: int
|
|
15
|
+
end_line: int
|
|
16
|
+
end_column: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Number:
|
|
21
|
+
value: int
|
|
22
|
+
span: SourceSpan
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Var:
|
|
27
|
+
name: str
|
|
28
|
+
span: SourceSpan
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class BinOp:
|
|
33
|
+
op: str # one of + - *
|
|
34
|
+
left: AExpr
|
|
35
|
+
right: AExpr
|
|
36
|
+
span: SourceSpan
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Compare:
|
|
41
|
+
op: str # one of = >
|
|
42
|
+
left: AExpr
|
|
43
|
+
right: AExpr
|
|
44
|
+
span: SourceSpan
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Or:
|
|
49
|
+
left: BExpr
|
|
50
|
+
right: BExpr
|
|
51
|
+
span: SourceSpan
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Not:
|
|
56
|
+
operand: BExpr
|
|
57
|
+
span: SourceSpan
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Assign:
|
|
62
|
+
name: str
|
|
63
|
+
value: AExpr
|
|
64
|
+
span: SourceSpan
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Seq:
|
|
69
|
+
"""S1; S2; ... flattened: sequencing is associative, and this keeps long programs shallow."""
|
|
70
|
+
|
|
71
|
+
statements: tuple[Stmt, ...]
|
|
72
|
+
span: SourceSpan
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class If:
|
|
77
|
+
condition: BExpr
|
|
78
|
+
then_branch: Stmt
|
|
79
|
+
else_branch: Stmt
|
|
80
|
+
span: SourceSpan
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class While:
|
|
85
|
+
condition: BExpr
|
|
86
|
+
body: Stmt
|
|
87
|
+
span: SourceSpan
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class Block:
|
|
92
|
+
body: Stmt
|
|
93
|
+
span: SourceSpan
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class Skip:
|
|
98
|
+
span: SourceSpan
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class Program:
|
|
103
|
+
body: Stmt
|
|
104
|
+
span: SourceSpan
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
AExpr = Number | Var | BinOp
|
|
108
|
+
BExpr = Compare | Or | Not
|
|
109
|
+
Expr = AExpr | BExpr
|
|
110
|
+
Stmt = Assign | Seq | If | While | Block | Skip
|
|
111
|
+
Node = Expr | Stmt | Program
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def to_dict(value: Any) -> Any:
|
|
115
|
+
"""Convert an AST node or span to plain JSON-compatible data."""
|
|
116
|
+
if isinstance(value, tuple):
|
|
117
|
+
return [to_dict(item) for item in value]
|
|
118
|
+
if is_dataclass(value):
|
|
119
|
+
data = {f.name: to_dict(getattr(value, f.name)) for f in fields(value)}
|
|
120
|
+
return data if isinstance(value, SourceSpan) else {"type": type(value).__name__, **data}
|
|
121
|
+
return value
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Command line interface: parse, check, run, trace. The JSON format is described in README."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sipl.ast import Program, to_dict
|
|
12
|
+
from sipl.diagnostics import Diagnostic, SiplRuntimeError, SiplSyntaxError
|
|
13
|
+
from sipl.interpreter import (
|
|
14
|
+
DEFAULT_MAX_STEPS,
|
|
15
|
+
AfterStatement,
|
|
16
|
+
BeforeStatement,
|
|
17
|
+
ConditionChecked,
|
|
18
|
+
Event,
|
|
19
|
+
Finished,
|
|
20
|
+
VariableChanged,
|
|
21
|
+
event_to_dict,
|
|
22
|
+
execute,
|
|
23
|
+
parse_state,
|
|
24
|
+
)
|
|
25
|
+
from sipl.parser import parse
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = 1
|
|
28
|
+
EXIT_OK = 0
|
|
29
|
+
EXIT_SYNTAX = 1
|
|
30
|
+
EXIT_USAGE = 2
|
|
31
|
+
EXIT_RUNTIME = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _positive_int(text: str) -> int:
|
|
35
|
+
value = int(text)
|
|
36
|
+
if value <= 0:
|
|
37
|
+
raise argparse.ArgumentTypeError("очікувалось додатне ціле число")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(prog="sipl", description="Інструменти мови SIPL")
|
|
43
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
for name, description in (
|
|
45
|
+
("parse", "вивести AST програми"),
|
|
46
|
+
("check", "перевірити синтаксис"),
|
|
47
|
+
("run", "виконати програму й вивести кінцевий стан"),
|
|
48
|
+
("trace", "виконати програму й вивести всі події"),
|
|
49
|
+
):
|
|
50
|
+
command = commands.add_parser(name, help=description, description=description)
|
|
51
|
+
command.add_argument("file", help="файл .sipl")
|
|
52
|
+
command.add_argument("--json", action="store_true", help="структурований вивід")
|
|
53
|
+
if name in ("run", "trace"):
|
|
54
|
+
command.add_argument("--input", default="", help='початковий стан, напр. "X=2 Y=10"')
|
|
55
|
+
command.add_argument(
|
|
56
|
+
"--max-steps",
|
|
57
|
+
type=_positive_int,
|
|
58
|
+
default=DEFAULT_MAX_STEPS,
|
|
59
|
+
help="ліміт кроків виконання (типово %(default)s)",
|
|
60
|
+
)
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _emit_json(command: str, ok: bool, diagnostics: list[Diagnostic], **fields: Any) -> None:
|
|
65
|
+
payload = {
|
|
66
|
+
"schema_version": SCHEMA_VERSION,
|
|
67
|
+
"command": command,
|
|
68
|
+
"ok": ok,
|
|
69
|
+
**fields,
|
|
70
|
+
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
|
71
|
+
}
|
|
72
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _failure_fields(command: str) -> dict[str, Any]:
|
|
76
|
+
"""Result fields of a command that stopped before producing them."""
|
|
77
|
+
fields: dict[str, dict[str, Any]] = {
|
|
78
|
+
"parse": {"ast": None},
|
|
79
|
+
"check": {},
|
|
80
|
+
"run": {"state": None, "steps": None},
|
|
81
|
+
"trace": {"events": [], "state": None, "steps": None},
|
|
82
|
+
}
|
|
83
|
+
return fields[command]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_ast(node: Any, indent: int = 0, label: str = "") -> None:
|
|
87
|
+
"""Print an AST tree from its JSON form, so text and JSON can never disagree."""
|
|
88
|
+
span = node["span"]
|
|
89
|
+
scalars = "".join(
|
|
90
|
+
f" {key}={value}"
|
|
91
|
+
for key, value in node.items()
|
|
92
|
+
if key not in ("type", "span") and not isinstance(value, (dict, list))
|
|
93
|
+
)
|
|
94
|
+
position = f"{span['start_line']}:{span['start_column']}"
|
|
95
|
+
print(f"{' ' * indent}{label}{node['type']}{scalars} @{position}")
|
|
96
|
+
for key, value in node.items():
|
|
97
|
+
if isinstance(value, dict) and key != "span":
|
|
98
|
+
_print_ast(value, indent + 1, f"{key}: ")
|
|
99
|
+
elif isinstance(value, list):
|
|
100
|
+
for item in value:
|
|
101
|
+
_print_ast(item, indent + 1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _print_state(state: dict[str, int]) -> None:
|
|
105
|
+
for name in sorted(state):
|
|
106
|
+
print(f"{name} = {state[name]}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _format_event(event: Event) -> str:
|
|
110
|
+
position = f"{event.span.start_line}:{event.span.start_column}"
|
|
111
|
+
match event:
|
|
112
|
+
case BeforeStatement(statement=statement) | AfterStatement(statement=statement):
|
|
113
|
+
details = statement
|
|
114
|
+
case VariableChanged(name=name, old=old, new=new):
|
|
115
|
+
details = f"{name}: {'—' if old is None else old} -> {new}"
|
|
116
|
+
case ConditionChecked(value=value):
|
|
117
|
+
details = "true" if value else "false"
|
|
118
|
+
case Finished(steps=steps):
|
|
119
|
+
details = f"кроків: {steps}"
|
|
120
|
+
return f"{position} {type(event).__name__} {details}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _execute(args: argparse.Namespace, program: Program) -> int:
|
|
124
|
+
try:
|
|
125
|
+
state = parse_state(args.input)
|
|
126
|
+
except ValueError as error:
|
|
127
|
+
return _fail(args, Diagnostic("invalid-input", str(error)), EXIT_USAGE)
|
|
128
|
+
|
|
129
|
+
tracing = args.command == "trace"
|
|
130
|
+
events: list[dict[str, Any]] = []
|
|
131
|
+
steps = None
|
|
132
|
+
try:
|
|
133
|
+
for event in execute(program, state, max_steps=args.max_steps):
|
|
134
|
+
if isinstance(event, Finished):
|
|
135
|
+
steps = event.steps
|
|
136
|
+
if tracing and args.json:
|
|
137
|
+
events.append(event_to_dict(event))
|
|
138
|
+
elif tracing:
|
|
139
|
+
print(_format_event(event))
|
|
140
|
+
except SiplRuntimeError as error:
|
|
141
|
+
return _fail(
|
|
142
|
+
args,
|
|
143
|
+
error.diagnostic,
|
|
144
|
+
EXIT_RUNTIME,
|
|
145
|
+
{"state": state, "steps": None, **({"events": events} if tracing else {})},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if args.json:
|
|
149
|
+
fields = {"state": state, "steps": steps, **({"events": events} if tracing else {})}
|
|
150
|
+
_emit_json(args.command, True, [], **fields)
|
|
151
|
+
else:
|
|
152
|
+
_print_state(state)
|
|
153
|
+
return EXIT_OK
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _fail(
|
|
157
|
+
args: argparse.Namespace,
|
|
158
|
+
diagnostic: Diagnostic,
|
|
159
|
+
code: int,
|
|
160
|
+
fields: dict[str, Any] | None = None,
|
|
161
|
+
) -> int:
|
|
162
|
+
if args.json:
|
|
163
|
+
_emit_json(
|
|
164
|
+
args.command,
|
|
165
|
+
False,
|
|
166
|
+
[diagnostic],
|
|
167
|
+
**(_failure_fields(args.command) if fields is None else fields),
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
print(diagnostic.format(args.file), file=sys.stderr)
|
|
171
|
+
return code
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
175
|
+
for stream in (sys.stdout, sys.stderr):
|
|
176
|
+
# Pipes on Windows default to a legacy code page that cannot encode all messages.
|
|
177
|
+
if hasattr(stream, "reconfigure"):
|
|
178
|
+
stream.reconfigure(encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
args = _build_parser().parse_args(argv)
|
|
181
|
+
try:
|
|
182
|
+
with open(args.file, encoding="utf-8-sig") as file:
|
|
183
|
+
text = file.read()
|
|
184
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
185
|
+
return _fail(
|
|
186
|
+
args,
|
|
187
|
+
Diagnostic("cannot-read-file", f"Не вдалося прочитати файл: {error}"),
|
|
188
|
+
EXIT_USAGE,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
program = parse(text)
|
|
193
|
+
except SiplSyntaxError as error:
|
|
194
|
+
return _fail(args, error.diagnostic, EXIT_SYNTAX)
|
|
195
|
+
|
|
196
|
+
if args.command == "parse":
|
|
197
|
+
ast = to_dict(program)
|
|
198
|
+
if args.json:
|
|
199
|
+
_emit_json("parse", True, [], ast=ast)
|
|
200
|
+
else:
|
|
201
|
+
_print_ast(ast)
|
|
202
|
+
return EXIT_OK
|
|
203
|
+
if args.command == "check":
|
|
204
|
+
if args.json:
|
|
205
|
+
_emit_json("check", True, [])
|
|
206
|
+
else:
|
|
207
|
+
print(f"{args.file}: синтаксис коректний")
|
|
208
|
+
return EXIT_OK
|
|
209
|
+
return _execute(args, program)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
sys.exit(main())
|