weavly 0.1.0__py3-none-any.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.
- weavly/__init__.py +1 -0
- weavly/cli.py +82 -0
- weavly/parsing/__init__.py +7 -0
- weavly/parsing/parser.py +254 -0
- weavly/parsing/syntax_errors.py +145 -0
- weavly/parsing/wvl_transformer.py +282 -0
- weavly/reporting.py +24 -0
- weavly/resources/__init__.py +0 -0
- weavly/resources/wvl-grammar.lark +241 -0
- weavly-0.1.0.dist-info/METADATA +77 -0
- weavly-0.1.0.dist-info/RECORD +15 -0
- weavly-0.1.0.dist-info/WHEEL +5 -0
- weavly-0.1.0.dist-info/entry_points.txt +2 -0
- weavly-0.1.0.dist-info/licenses/LICENSE +21 -0
- weavly-0.1.0.dist-info/top_level.txt +1 -0
weavly/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
weavly/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from importlib.metadata import version as package_version
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from .parsing import (
|
|
7
|
+
WVL_SOURCE_EXTENSION,
|
|
8
|
+
build_all_files,
|
|
9
|
+
)
|
|
10
|
+
from .reporting import report_error
|
|
11
|
+
|
|
12
|
+
SOURCE_DIR = Path("src")
|
|
13
|
+
BUILD_DIR = Path("build")
|
|
14
|
+
|
|
15
|
+
NODE_INIT_STRING = (
|
|
16
|
+
"@env\n"
|
|
17
|
+
"name: string = \"World\"\n"
|
|
18
|
+
"@endenv\n"
|
|
19
|
+
"\n"
|
|
20
|
+
"@node first_node\n"
|
|
21
|
+
"Hello $name!\n"
|
|
22
|
+
"@endnode\n"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_version(value: bool) -> None:
|
|
29
|
+
if value:
|
|
30
|
+
typer.echo(f"weavly {package_version('weavly')}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def main(
|
|
36
|
+
version: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--version",
|
|
39
|
+
callback=_print_version,
|
|
40
|
+
is_eager=True,
|
|
41
|
+
help="Show the version and exit.",
|
|
42
|
+
),
|
|
43
|
+
):
|
|
44
|
+
"""Compiler for the Weavly dialog scripting language."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def build(
|
|
49
|
+
pretty: bool = typer.Option(
|
|
50
|
+
False, "--pretty", help="Write indented, human-readable JSON."
|
|
51
|
+
),
|
|
52
|
+
):
|
|
53
|
+
"""Compile src/**/*.wvl into build/."""
|
|
54
|
+
count = build_all_files(SOURCE_DIR, BUILD_DIR, pretty)
|
|
55
|
+
files = "file" if count == 1 else "files"
|
|
56
|
+
typer.echo(f"Built {count} {files} into {BUILD_DIR.as_posix()}/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def init(
|
|
61
|
+
name: str = typer.Argument(
|
|
62
|
+
None, help="Directory to create. Defaults to the current directory."
|
|
63
|
+
),
|
|
64
|
+
):
|
|
65
|
+
"""Create a new Weavly project with a starter src/nodes.wvl."""
|
|
66
|
+
if name:
|
|
67
|
+
base = Path(name)
|
|
68
|
+
if base.exists():
|
|
69
|
+
report_error(f"project '{name}' already exists")
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
base.mkdir()
|
|
72
|
+
else:
|
|
73
|
+
base = Path()
|
|
74
|
+
|
|
75
|
+
src: Path = base / "src"
|
|
76
|
+
if src.exists():
|
|
77
|
+
report_error(f"directory '{src.as_posix()}' already exists")
|
|
78
|
+
raise typer.Exit(code=1)
|
|
79
|
+
src.mkdir()
|
|
80
|
+
nodes_file = src / f"nodes{WVL_SOURCE_EXTENSION}"
|
|
81
|
+
nodes_file.write_text(NODE_INIT_STRING, encoding="utf-8")
|
|
82
|
+
typer.echo(f"Created {nodes_file.as_posix()}")
|
weavly/parsing/parser.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from lark import Lark, Token, Transformer, Tree
|
|
8
|
+
from lark.exceptions import UnexpectedInput, VisitError
|
|
9
|
+
|
|
10
|
+
from ..reporting import report_error
|
|
11
|
+
from .syntax_errors import describe_syntax_error, terminal_names
|
|
12
|
+
from .wvl_transformer import InvalidStringError, WvlTransformer
|
|
13
|
+
|
|
14
|
+
PARSER_TYPE = "lalr"
|
|
15
|
+
ENCODING = "utf-8"
|
|
16
|
+
SOURCE_ENCODING = "utf-8-sig"
|
|
17
|
+
|
|
18
|
+
WVL_SOURCE_EXTENSION = ".wvl"
|
|
19
|
+
WVL_BUILD_EXTENSION = ".wvl.json"
|
|
20
|
+
WVL_GRAMMAR_FILE = "wvl-grammar.lark"
|
|
21
|
+
ENV_BUILD_FILE = "env.json"
|
|
22
|
+
|
|
23
|
+
_DECLARATION_RULES = frozenset(
|
|
24
|
+
{"number_declaration", "string_declaration", "flag_declaration"}
|
|
25
|
+
)
|
|
26
|
+
_NODE_RULES = frozenset({"node_start"})
|
|
27
|
+
_GOTO_RULES = frozenset({"goto", "inline_goto"})
|
|
28
|
+
|
|
29
|
+
Location = tuple[Path, int, int]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_all_files(src_dir: Path, build_dir: Path, pretty: bool) -> int:
|
|
33
|
+
if not src_dir.is_dir():
|
|
34
|
+
report_error(
|
|
35
|
+
f"no '{src_dir.as_posix()}' directory found. Run 'weavly init' to create "
|
|
36
|
+
"a project or run the build from the project root."
|
|
37
|
+
)
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
grammar = _load_grammar(WVL_GRAMMAR_FILE)
|
|
41
|
+
parser = _build_parser(grammar, PARSER_TYPE)
|
|
42
|
+
names = terminal_names(parser)
|
|
43
|
+
transformer = WvlTransformer()
|
|
44
|
+
|
|
45
|
+
declarations: list[dict] = []
|
|
46
|
+
outputs: list[tuple[Path, dict]] = []
|
|
47
|
+
# name -> location of the first declaration seen with that name.
|
|
48
|
+
declared: dict[str, Location] = {}
|
|
49
|
+
node_ids: dict[str, Location] = {}
|
|
50
|
+
gotos: list[tuple[str, Location]] = []
|
|
51
|
+
validation_errors: list[tuple[Location, str]] = []
|
|
52
|
+
failed = False
|
|
53
|
+
|
|
54
|
+
for file in sorted(src_dir.rglob(f"*{WVL_SOURCE_EXTENSION}")):
|
|
55
|
+
if not file.is_file():
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
relative_path = file.relative_to(src_dir)
|
|
59
|
+
out_file = relative_path.with_suffix(WVL_BUILD_EXTENSION)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
text = file.read_text(encoding=SOURCE_ENCODING)
|
|
63
|
+
except UnicodeDecodeError as e:
|
|
64
|
+
_report_encoding_error(e, file)
|
|
65
|
+
failed = True
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
tree = parser.parse(text)
|
|
70
|
+
except UnexpectedInput as e:
|
|
71
|
+
_report_parse_error(e, file, text, names)
|
|
72
|
+
failed = True
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
data = transformer.transform(tree)
|
|
77
|
+
except VisitError as e:
|
|
78
|
+
if not isinstance(e.orig_exc, InvalidStringError):
|
|
79
|
+
raise
|
|
80
|
+
_report_string_error(e.orig_exc, file)
|
|
81
|
+
failed = True
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Source locations come from the raw tree; the transformed data has no
|
|
85
|
+
# line numbers.
|
|
86
|
+
_record_unique(
|
|
87
|
+
_id_locations(tree, _DECLARATION_RULES, file),
|
|
88
|
+
declared,
|
|
89
|
+
"variable",
|
|
90
|
+
validation_errors,
|
|
91
|
+
)
|
|
92
|
+
_record_unique(
|
|
93
|
+
_id_locations(tree, _NODE_RULES, file), node_ids, "node id", validation_errors
|
|
94
|
+
)
|
|
95
|
+
gotos.extend(_id_locations(tree, _GOTO_RULES, file))
|
|
96
|
+
validation_errors.extend(_number_range_errors(tree, file))
|
|
97
|
+
declarations.extend(data.get("declarations", []))
|
|
98
|
+
|
|
99
|
+
outputs.append((out_file, {"nodes": data["nodes"]}))
|
|
100
|
+
|
|
101
|
+
if failed:
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
validation_errors.extend(
|
|
105
|
+
(location, f"goto target '{target}' matches no node")
|
|
106
|
+
for target, location in gotos
|
|
107
|
+
if target not in node_ids
|
|
108
|
+
)
|
|
109
|
+
if validation_errors:
|
|
110
|
+
for (file, line, column), message in sorted(validation_errors):
|
|
111
|
+
report_error(message, file, line, column)
|
|
112
|
+
raise typer.Exit(code=1)
|
|
113
|
+
|
|
114
|
+
file_count = len(outputs)
|
|
115
|
+
outputs.append((Path(ENV_BUILD_FILE), {"declarations": declarations}))
|
|
116
|
+
_replace_build_dir(build_dir, outputs, pretty)
|
|
117
|
+
return file_count
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _replace_build_dir(
|
|
121
|
+
build_dir: Path, outputs: list[tuple[Path, dict]], pretty: bool
|
|
122
|
+
) -> None:
|
|
123
|
+
tmp_dir = build_dir.with_name(f".{build_dir.name}.tmp")
|
|
124
|
+
old_dir = build_dir.with_name(f".{build_dir.name}.old")
|
|
125
|
+
try:
|
|
126
|
+
for leftover in (tmp_dir, old_dir):
|
|
127
|
+
if leftover.exists():
|
|
128
|
+
shutil.rmtree(leftover)
|
|
129
|
+
for out_file, data in outputs:
|
|
130
|
+
_write_json(data, tmp_dir / out_file, pretty)
|
|
131
|
+
if build_dir.exists():
|
|
132
|
+
build_dir.rename(old_dir)
|
|
133
|
+
tmp_dir.rename(build_dir)
|
|
134
|
+
except OSError as e:
|
|
135
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
136
|
+
report_error(
|
|
137
|
+
f"could not update '{build_dir.as_posix()}': {e.strerror or e}. "
|
|
138
|
+
"Close any program using its files and build again."
|
|
139
|
+
)
|
|
140
|
+
raise typer.Exit(code=1)
|
|
141
|
+
shutil.rmtree(old_dir, ignore_errors=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _id_locations(
|
|
145
|
+
tree: Tree, rules: frozenset[str], file: Path
|
|
146
|
+
) -> list[tuple[str, Location]]:
|
|
147
|
+
"""Return (id, location) for the leading ID of every matching rule, in source order."""
|
|
148
|
+
locations = []
|
|
149
|
+
for subtree in tree.iter_subtrees_topdown():
|
|
150
|
+
if subtree.data in rules:
|
|
151
|
+
id_token = subtree.children[0]
|
|
152
|
+
locations.append((str(id_token), (file, id_token.line, id_token.column)))
|
|
153
|
+
return locations
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _record_unique(
|
|
157
|
+
locations: list[tuple[str, Location]],
|
|
158
|
+
seen: dict[str, Location],
|
|
159
|
+
kind: str,
|
|
160
|
+
errors: list[tuple[Location, str]],
|
|
161
|
+
) -> None:
|
|
162
|
+
for name, location in locations:
|
|
163
|
+
if name in seen:
|
|
164
|
+
prev_file, prev_line, _ = seen[name]
|
|
165
|
+
errors.append(
|
|
166
|
+
(
|
|
167
|
+
location,
|
|
168
|
+
f"duplicate {kind} '{name}', first declared at "
|
|
169
|
+
f"{prev_file.as_posix()}:{prev_line}",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
seen[name] = location
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _number_range_errors(tree: Tree, file: Path) -> list[tuple[Location, str]]:
|
|
177
|
+
errors = []
|
|
178
|
+
for declaration in tree.find_data("number_declaration"):
|
|
179
|
+
name_token, *numbers = declaration.children
|
|
180
|
+
minimum, maximum, value = (_number(child) for child in numbers)
|
|
181
|
+
location = (file, name_token.line, name_token.column)
|
|
182
|
+
name = str(name_token)
|
|
183
|
+
|
|
184
|
+
if minimum is not None and maximum is not None and minimum > maximum:
|
|
185
|
+
errors.append(
|
|
186
|
+
(location, f"number '{name}' has min {minimum:g} greater than max {maximum:g}")
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
default = f"default {value:g}" if value is not None else "implicit default 0"
|
|
191
|
+
value = value or 0.0
|
|
192
|
+
if minimum is not None and value < minimum:
|
|
193
|
+
errors.append(
|
|
194
|
+
(location, f"number '{name}' has {default} below its min {minimum:g}")
|
|
195
|
+
)
|
|
196
|
+
elif maximum is not None and value > maximum:
|
|
197
|
+
errors.append(
|
|
198
|
+
(location, f"number '{name}' has {default} above its max {maximum:g}")
|
|
199
|
+
)
|
|
200
|
+
return errors
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _number(child: Tree | Token | None) -> float | None:
|
|
204
|
+
if child is None:
|
|
205
|
+
return None
|
|
206
|
+
if isinstance(child, Tree):
|
|
207
|
+
return -float(child.children[0])
|
|
208
|
+
return float(child)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _write_json(data: dict, out_file: Path, pretty: bool) -> None:
|
|
212
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
if pretty:
|
|
214
|
+
out_file.write_text(json.dumps(data, indent=2), encoding=ENCODING)
|
|
215
|
+
else:
|
|
216
|
+
out_file.write_text(json.dumps(data, separators=(",", ":")), encoding=ENCODING)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _load_grammar(grammar_file: str) -> str:
|
|
220
|
+
grammar = (
|
|
221
|
+
resources.files("weavly.resources")
|
|
222
|
+
.joinpath(grammar_file)
|
|
223
|
+
.read_text(encoding=ENCODING)
|
|
224
|
+
)
|
|
225
|
+
return grammar
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _build_parser(grammar: str, parser_type: str) -> Lark:
|
|
229
|
+
return Lark(grammar, parser=parser_type)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse(parser: Lark, text: str, transformer: Transformer) -> dict:
|
|
233
|
+
tree = parser.parse(text)
|
|
234
|
+
data = transformer.transform(tree)
|
|
235
|
+
return data
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _report_encoding_error(error: UnicodeDecodeError, file: Path) -> None:
|
|
239
|
+
line = error.object[: error.start].count(b"\n") + 1
|
|
240
|
+
report_error("invalid encoding, files must be saved as UTF-8", file, line)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _report_string_error(error: InvalidStringError, file: Path) -> None:
|
|
244
|
+
token = error.token
|
|
245
|
+
report_error(
|
|
246
|
+
f"invalid string {token.value}: {error.reason}", file, token.line, token.column
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _report_parse_error(
|
|
251
|
+
error: UnexpectedInput, file: Path, text: str, names: dict[str, str]
|
|
252
|
+
) -> None:
|
|
253
|
+
message, details = describe_syntax_error(error, text, names)
|
|
254
|
+
report_error(message, file, error.line, error.column, details)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from lark import Lark, Token
|
|
4
|
+
from lark.exceptions import UnexpectedCharacters, UnexpectedInput, UnexpectedToken
|
|
5
|
+
from lark.lexer import PatternStr
|
|
6
|
+
|
|
7
|
+
END = "$END"
|
|
8
|
+
|
|
9
|
+
PATTERN_NAMES = {
|
|
10
|
+
"NUMBER": "a number",
|
|
11
|
+
"STRING": "a quoted string",
|
|
12
|
+
"ID": "a name",
|
|
13
|
+
"TEXT": "text",
|
|
14
|
+
"CHARACTER_NAME": "a character name",
|
|
15
|
+
"COMP_OP": "a comparison operator",
|
|
16
|
+
"MATCH_MODIFIER": "'first', 'last' or 'all'",
|
|
17
|
+
"_COMMAND": "a @command",
|
|
18
|
+
"_NEWLINE": "end of line",
|
|
19
|
+
"COMMENT": "a comment",
|
|
20
|
+
"WS_INLINE": "whitespace",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_BLOCKS = {
|
|
24
|
+
"@node": "@endnode",
|
|
25
|
+
"@env": "@endenv",
|
|
26
|
+
"@if": "@endif",
|
|
27
|
+
"@options": "@endoptions",
|
|
28
|
+
"@random": "@endrandom",
|
|
29
|
+
"@match": "@endmatch",
|
|
30
|
+
}
|
|
31
|
+
_OPENERS = {closer: opener for opener, closer in _BLOCKS.items()}
|
|
32
|
+
_PARENTS = {
|
|
33
|
+
"@option": "@options",
|
|
34
|
+
"@hint": "@options",
|
|
35
|
+
"@case": "@random",
|
|
36
|
+
"@when": "@match",
|
|
37
|
+
"@elif": "@if",
|
|
38
|
+
"@else": "@if",
|
|
39
|
+
}
|
|
40
|
+
_KEYWORD = re.compile(r"\s*(@\w+)")
|
|
41
|
+
_MAX_TOKEN_LENGTH = 40
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def terminal_names(parser: Lark) -> dict[str, str]:
|
|
45
|
+
names = {END: "end of file"}
|
|
46
|
+
for terminal in parser.terminals:
|
|
47
|
+
if isinstance(terminal.pattern, PatternStr):
|
|
48
|
+
names[terminal.name] = f"'{terminal.pattern.value}'"
|
|
49
|
+
else:
|
|
50
|
+
names[terminal.name] = PATTERN_NAMES.get(terminal.name, terminal.name)
|
|
51
|
+
return names
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def describe_syntax_error(
|
|
55
|
+
error: UnexpectedInput, text: str, names: dict[str, str]
|
|
56
|
+
) -> tuple[str, list[str]]:
|
|
57
|
+
lines = text.splitlines()
|
|
58
|
+
details = _excerpt(lines, error.line, error.column)
|
|
59
|
+
|
|
60
|
+
if isinstance(error, UnexpectedToken):
|
|
61
|
+
message = f"unexpected {_describe_token(error.token, names)}"
|
|
62
|
+
hint = _block_hint(error.token, lines, error.line)
|
|
63
|
+
elif isinstance(error, UnexpectedCharacters):
|
|
64
|
+
message = f"unexpected character {error.char!r}"
|
|
65
|
+
hint = None
|
|
66
|
+
else:
|
|
67
|
+
return "syntax error", details
|
|
68
|
+
|
|
69
|
+
if hint:
|
|
70
|
+
details.append(f"hint: {hint}")
|
|
71
|
+
else:
|
|
72
|
+
expected = getattr(error, "expected", None) or getattr(error, "allowed", None)
|
|
73
|
+
if expected:
|
|
74
|
+
details.append(_expected(sorted({names.get(n, n) for n in expected})))
|
|
75
|
+
return message, details
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _describe_token(token: Token, names: dict[str, str]) -> str:
|
|
79
|
+
if token.type in (END, "_NEWLINE"):
|
|
80
|
+
return names[token.type]
|
|
81
|
+
value = str(token.value).strip()
|
|
82
|
+
if token.type == "TEXT":
|
|
83
|
+
value = value.split()[0]
|
|
84
|
+
if len(value) > _MAX_TOKEN_LENGTH:
|
|
85
|
+
value = value[:_MAX_TOKEN_LENGTH] + "..."
|
|
86
|
+
return f"'{value}'"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expected(names: list[str]) -> str:
|
|
90
|
+
if len(names) == 1:
|
|
91
|
+
return f"expected {names[0]}"
|
|
92
|
+
return f"expected one of: {', '.join(names)}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _excerpt(lines: list[str], line: int, column: int) -> list[str]:
|
|
96
|
+
if not 1 <= line <= len(lines):
|
|
97
|
+
return []
|
|
98
|
+
source = lines[line - 1]
|
|
99
|
+
gutter = str(line)
|
|
100
|
+
pad = "".join("\t" if char == "\t" else " " for char in source[: column - 1])
|
|
101
|
+
return [f"{gutter} | {source}", f"{' ' * len(gutter)} | {pad}^"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _block_hint(token: Token, lines: list[str], line: int) -> str | None:
|
|
105
|
+
if token.type == END:
|
|
106
|
+
stack = _open_blocks(lines)
|
|
107
|
+
if not stack:
|
|
108
|
+
return None
|
|
109
|
+
opener, opened_at = stack[-1]
|
|
110
|
+
return f"the {opener} block opened at line {opened_at} is missing its {_BLOCKS[opener]}"
|
|
111
|
+
|
|
112
|
+
keyword = str(token.value).strip()
|
|
113
|
+
stack = _open_blocks(lines[: line - 1])
|
|
114
|
+
if keyword in _PARENTS:
|
|
115
|
+
parent = _PARENTS[keyword]
|
|
116
|
+
if not stack or stack[-1][0] != parent:
|
|
117
|
+
return f"{keyword} can only be used inside an {parent} block"
|
|
118
|
+
return None
|
|
119
|
+
if keyword not in _OPENERS:
|
|
120
|
+
return None
|
|
121
|
+
closer = keyword
|
|
122
|
+
opener = _OPENERS[closer]
|
|
123
|
+
if all(open_block != opener for open_block, _ in stack):
|
|
124
|
+
return f"there is no open {opener} block for {closer} to close"
|
|
125
|
+
top, opened_at = stack[-1]
|
|
126
|
+
if top != opener:
|
|
127
|
+
return (
|
|
128
|
+
f"the {top} block opened at line {opened_at} must be closed with "
|
|
129
|
+
f"{_BLOCKS[top]} first"
|
|
130
|
+
)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _open_blocks(lines: list[str]) -> list[tuple[str, int]]:
|
|
135
|
+
stack: list[tuple[str, int]] = []
|
|
136
|
+
for number, source in enumerate(lines, start=1):
|
|
137
|
+
match = _KEYWORD.match(source)
|
|
138
|
+
if not match:
|
|
139
|
+
continue
|
|
140
|
+
keyword = match.group(1)
|
|
141
|
+
if keyword in _BLOCKS:
|
|
142
|
+
stack.append((keyword, number))
|
|
143
|
+
elif keyword in _OPENERS and stack and stack[-1][0] == _OPENERS[keyword]:
|
|
144
|
+
stack.pop()
|
|
145
|
+
return stack
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from lark import Transformer, v_args
|
|
5
|
+
from lark.visitors import Discard
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidStringError(ValueError):
|
|
9
|
+
def __init__(self, token: Any, reason: str) -> None:
|
|
10
|
+
super().__init__(reason)
|
|
11
|
+
self.token = token
|
|
12
|
+
self.reason = reason
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@v_args(inline=True)
|
|
16
|
+
class WvlTransformer(Transformer):
|
|
17
|
+
# =====================
|
|
18
|
+
# Entry / Nodes
|
|
19
|
+
# =====================
|
|
20
|
+
|
|
21
|
+
def start(self, *children) -> dict[str, list]:
|
|
22
|
+
nodes = [child for child in children if isinstance(child, dict)]
|
|
23
|
+
declarations = [
|
|
24
|
+
decl for child in children if isinstance(child, list) for decl in child
|
|
25
|
+
]
|
|
26
|
+
result: dict[str, list] = {"nodes": nodes}
|
|
27
|
+
if declarations:
|
|
28
|
+
result["declarations"] = declarations
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
def node(self, node_start: str, body: list, _node_end: None) -> dict[str, Any]:
|
|
32
|
+
return {"id": node_start, "body": body}
|
|
33
|
+
|
|
34
|
+
# =====================
|
|
35
|
+
# Env Block
|
|
36
|
+
# =====================
|
|
37
|
+
|
|
38
|
+
def env_block(self, *declarations) -> list:
|
|
39
|
+
return list(declarations)
|
|
40
|
+
|
|
41
|
+
def string_declaration(self, name: str, value: str | None = None) -> dict[str, str]:
|
|
42
|
+
if value is None:
|
|
43
|
+
value = ""
|
|
44
|
+
return {"type": "string", "name": name, "value": str(value)}
|
|
45
|
+
|
|
46
|
+
def number_declaration(
|
|
47
|
+
self, name: str, min=None, max=None, value: float | None = None
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
if value is None:
|
|
50
|
+
value = 0.0
|
|
51
|
+
return {"type": "number", "name": name, "value": float(value), "min": min, "max": max}
|
|
52
|
+
|
|
53
|
+
def flag_declaration(self, name: str, value: bool | None = None) -> dict[str, Any]:
|
|
54
|
+
if value is None:
|
|
55
|
+
value = False
|
|
56
|
+
return {"type": "flag", "name": name, "value": bool(value)}
|
|
57
|
+
|
|
58
|
+
def node_start(self, id: str) -> str:
|
|
59
|
+
return id
|
|
60
|
+
|
|
61
|
+
def node_end(self) -> None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def body(self, *statements) -> list:
|
|
65
|
+
return list(statements)
|
|
66
|
+
|
|
67
|
+
def inline_goto(self, id) -> dict[str, str]:
|
|
68
|
+
return self.goto(id)
|
|
69
|
+
|
|
70
|
+
# =====================
|
|
71
|
+
# Lines / Statements
|
|
72
|
+
# =====================
|
|
73
|
+
|
|
74
|
+
def narration_line(self, text: str) -> dict[str, str]:
|
|
75
|
+
return {"type": "narration", "text": text}
|
|
76
|
+
|
|
77
|
+
def character_line(self, var: dict, text: str) -> dict[str, str]:
|
|
78
|
+
return {"type": "character", "name": var["variable"], "id": True, "text": text}
|
|
79
|
+
|
|
80
|
+
def named_character_line(self, name: str, text: str) -> dict[str, str]:
|
|
81
|
+
return {"type": "character", "name": name, "id": False, "text": text}
|
|
82
|
+
|
|
83
|
+
def blank_line(self) -> Any:
|
|
84
|
+
return Discard
|
|
85
|
+
|
|
86
|
+
def set(self, variable: dict, expression: Any) -> dict[str, Any]:
|
|
87
|
+
return {"type": "set", "id": variable["variable"], "expression": expression}
|
|
88
|
+
|
|
89
|
+
def increase(self, var: dict, number: float | None) -> dict[str, Any]:
|
|
90
|
+
if number is None:
|
|
91
|
+
number = 1.0
|
|
92
|
+
expression = {"op": "+", "left": {"variable": var["variable"]}, "right": number}
|
|
93
|
+
return self.set(var, expression)
|
|
94
|
+
|
|
95
|
+
def decrease(self, var: dict, number: float | None) -> dict[str, Any]:
|
|
96
|
+
if number is None:
|
|
97
|
+
number = 1.0
|
|
98
|
+
expression = {"op": "-", "left": {"variable": var["variable"]}, "right": number}
|
|
99
|
+
return self.set(var, expression)
|
|
100
|
+
|
|
101
|
+
def setflag(self, var: dict) -> dict[str, Any]:
|
|
102
|
+
return self.set(var, True)
|
|
103
|
+
|
|
104
|
+
def clearflag(self, var: dict) -> dict[str, Any]:
|
|
105
|
+
return self.set(var, False)
|
|
106
|
+
|
|
107
|
+
def goto(self, id: str) -> dict[str, str]:
|
|
108
|
+
return {"type": "goto", "id": id}
|
|
109
|
+
|
|
110
|
+
def finish(self) -> dict[str, str]:
|
|
111
|
+
return {"type": "finish"}
|
|
112
|
+
|
|
113
|
+
def command(self, id: str, text: str | None) -> dict[str, str]:
|
|
114
|
+
if text is None:
|
|
115
|
+
text = ""
|
|
116
|
+
return {"type": "command", "id": id, "text": text}
|
|
117
|
+
|
|
118
|
+
def continue_(self, text: str, statement: dict[str, Any] = None) -> dict[str, Any]:
|
|
119
|
+
if statement is None:
|
|
120
|
+
body = []
|
|
121
|
+
else:
|
|
122
|
+
body = [statement]
|
|
123
|
+
return self.option_block(self.option(True, text, body))
|
|
124
|
+
|
|
125
|
+
# =====================
|
|
126
|
+
# If Block
|
|
127
|
+
# =====================
|
|
128
|
+
|
|
129
|
+
def if_block(
|
|
130
|
+
self, if_: dict, elif_list: list | None, else_: dict | None
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
cases = [if_]
|
|
133
|
+
if elif_list is not None:
|
|
134
|
+
cases.extend(elif_list)
|
|
135
|
+
if else_ is not None:
|
|
136
|
+
cases.append(else_)
|
|
137
|
+
return {"type": "match", "modifier": "first", "cases": cases}
|
|
138
|
+
|
|
139
|
+
def if_(self, condition: Any, body: list) -> dict[str, Any]:
|
|
140
|
+
if not isinstance(body, list):
|
|
141
|
+
body = [body]
|
|
142
|
+
return {"condition": condition, "body": body}
|
|
143
|
+
|
|
144
|
+
def elif_(self, condition: Any, body: list) -> dict[str, Any]:
|
|
145
|
+
return self.if_(condition, body)
|
|
146
|
+
|
|
147
|
+
def else_(self, body: list) -> dict[str, Any]:
|
|
148
|
+
return self.if_(True, body)
|
|
149
|
+
|
|
150
|
+
def elif_list(self, *elifs) -> list:
|
|
151
|
+
return list(elifs)
|
|
152
|
+
|
|
153
|
+
# =====================
|
|
154
|
+
# Option Block
|
|
155
|
+
# =====================
|
|
156
|
+
|
|
157
|
+
def option_block(self, *items) -> dict[str, Any]:
|
|
158
|
+
return {"type": "option", "items": list(items)}
|
|
159
|
+
|
|
160
|
+
def option(self, condition: Any | None, text: str, body: list | dict, hint: bool = False) -> dict[str, Any]:
|
|
161
|
+
if condition is None:
|
|
162
|
+
condition = True
|
|
163
|
+
if not isinstance(body, list):
|
|
164
|
+
body = [body]
|
|
165
|
+
return {"condition": condition, "text": text, "body": body, "hint": hint}
|
|
166
|
+
|
|
167
|
+
def hint_option(self, condition: Any | None, text: str) -> dict[str, Any]:
|
|
168
|
+
return self.option(condition, text, [], hint=True)
|
|
169
|
+
|
|
170
|
+
# =====================
|
|
171
|
+
# Random Block
|
|
172
|
+
# =====================
|
|
173
|
+
|
|
174
|
+
def random_block(self, *cases) -> dict[str, Any]:
|
|
175
|
+
return {"type": "random", "cases": list(cases)}
|
|
176
|
+
|
|
177
|
+
def case(self, condition: Any | None, weight: Any, body: list) -> dict[str, Any]:
|
|
178
|
+
if condition is None:
|
|
179
|
+
condition = True
|
|
180
|
+
if weight is None:
|
|
181
|
+
weight = 1.0
|
|
182
|
+
if not isinstance(body, list):
|
|
183
|
+
body = [body]
|
|
184
|
+
return {"condition": condition, "weight": weight, "body": body}
|
|
185
|
+
|
|
186
|
+
# =====================
|
|
187
|
+
# Match Block
|
|
188
|
+
# =====================
|
|
189
|
+
|
|
190
|
+
def match_block(self, modifier, *cases) -> dict[str, Any]:
|
|
191
|
+
if modifier is None:
|
|
192
|
+
modifier = "first"
|
|
193
|
+
return {"type": "match", "modifier": modifier, "cases": list(cases)}
|
|
194
|
+
|
|
195
|
+
def MATCH_MODIFIER(self, token: Any) -> str:
|
|
196
|
+
return str(token).lstrip()
|
|
197
|
+
|
|
198
|
+
def when(self, condition: Any, body: list) -> dict[str, Any]:
|
|
199
|
+
if not isinstance(body, list):
|
|
200
|
+
body = [body]
|
|
201
|
+
return {"condition": condition, "body": body}
|
|
202
|
+
|
|
203
|
+
# =====================
|
|
204
|
+
# Expressions
|
|
205
|
+
# =====================
|
|
206
|
+
|
|
207
|
+
def condition(self, expression: Any) -> Any:
|
|
208
|
+
return expression
|
|
209
|
+
|
|
210
|
+
def or_(self, left: Any, right: Any) -> dict[str, Any]:
|
|
211
|
+
return {"op": "or", "left": left, "right": right}
|
|
212
|
+
|
|
213
|
+
def and_(self, left: Any, right: Any) -> dict[str, Any]:
|
|
214
|
+
return {"op": "and", "left": left, "right": right}
|
|
215
|
+
|
|
216
|
+
def not_(self, expression: Any) -> dict[str, Any]:
|
|
217
|
+
return {"op": "not", "expression": expression}
|
|
218
|
+
|
|
219
|
+
def compare(self, left: Any, operator: str, right: Any) -> dict[str, Any]:
|
|
220
|
+
return {"op": operator, "left": left, "right": right}
|
|
221
|
+
|
|
222
|
+
def add(self, left: Any, right: Any) -> dict[str, Any]:
|
|
223
|
+
return {"op": "+", "left": left, "right": right}
|
|
224
|
+
|
|
225
|
+
def sub(self, left: Any, right: Any) -> dict[str, Any]:
|
|
226
|
+
return {"op": "-", "left": left, "right": right}
|
|
227
|
+
|
|
228
|
+
def negative_number(self, number: float) -> float:
|
|
229
|
+
return -number
|
|
230
|
+
|
|
231
|
+
def neg(self, operand: Any) -> Any:
|
|
232
|
+
if isinstance(operand, float):
|
|
233
|
+
return -operand
|
|
234
|
+
return {"op": "-", "left": 0.0, "right": operand}
|
|
235
|
+
|
|
236
|
+
def mul(self, left: Any, right: Any) -> dict[str, Any]:
|
|
237
|
+
return {"op": "*", "left": left, "right": right}
|
|
238
|
+
|
|
239
|
+
def div(self, left: Any, right: Any) -> dict[str, Any]:
|
|
240
|
+
return {"op": "/", "left": left, "right": right}
|
|
241
|
+
|
|
242
|
+
def variable(self, id: str) -> dict[str, str]:
|
|
243
|
+
return {"variable": id}
|
|
244
|
+
|
|
245
|
+
# =====================
|
|
246
|
+
# Tokens
|
|
247
|
+
# =====================
|
|
248
|
+
|
|
249
|
+
def TEXT(self, token: Any) -> str:
|
|
250
|
+
return str(token).lstrip()
|
|
251
|
+
|
|
252
|
+
def CHARACTER_NAME(self, token: Any) -> str:
|
|
253
|
+
return str(token).lstrip()
|
|
254
|
+
|
|
255
|
+
def COMP_OP(self, token: Any) -> str:
|
|
256
|
+
return str(token)
|
|
257
|
+
|
|
258
|
+
def TRUE(self, token: Any) -> bool:
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
def FALSE(self, token: Any) -> bool:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
def ID(self, token: Any) -> str:
|
|
265
|
+
return str(token)
|
|
266
|
+
|
|
267
|
+
def NUMBER(self, token: Any) -> float:
|
|
268
|
+
return float(token)
|
|
269
|
+
|
|
270
|
+
def STRING(self, token: Any) -> str:
|
|
271
|
+
try:
|
|
272
|
+
return json.loads(str(token), strict=False)
|
|
273
|
+
except json.JSONDecodeError as e:
|
|
274
|
+
raise InvalidStringError(token, e.msg) from e
|
|
275
|
+
|
|
276
|
+
# =====================
|
|
277
|
+
# Default for debugging
|
|
278
|
+
# =====================
|
|
279
|
+
|
|
280
|
+
def __default__(self, data: str, children: list, meta: Any) -> None:
|
|
281
|
+
# raise here to catch unhandled rules early
|
|
282
|
+
raise ValueError(f"Unhandled rule: {data} children={children}")
|
weavly/reporting.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def report_error(
|
|
7
|
+
message: str,
|
|
8
|
+
file: Path | None = None,
|
|
9
|
+
line: int | None = None,
|
|
10
|
+
column: int | None = None,
|
|
11
|
+
details: list[str] | None = None,
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Print `file:line:column: error: message` to stderr, plus indented details."""
|
|
14
|
+
if file is not None:
|
|
15
|
+
location = file.as_posix()
|
|
16
|
+
if line is not None:
|
|
17
|
+
location += f":{line}"
|
|
18
|
+
if column is not None:
|
|
19
|
+
location += f":{column}"
|
|
20
|
+
typer.secho(f"{location}: ", nl=False, bold=True, err=True)
|
|
21
|
+
typer.secho("error: ", nl=False, fg=typer.colors.RED, bold=True, err=True)
|
|
22
|
+
typer.echo(message, err=True)
|
|
23
|
+
for detail in details or []:
|
|
24
|
+
typer.echo(f" {detail}", err=True)
|
|
File without changes
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// =====================
|
|
2
|
+
// Entry / Nodes
|
|
3
|
+
// =====================
|
|
4
|
+
|
|
5
|
+
start: (env_block | node)+
|
|
6
|
+
|
|
7
|
+
// =====================
|
|
8
|
+
// Env Block
|
|
9
|
+
// =====================
|
|
10
|
+
|
|
11
|
+
env_block: _NEWLINE* _ENV _NEWLINE declaration+ _ENDENV _NEWLINE*
|
|
12
|
+
|
|
13
|
+
?declaration: number_declaration
|
|
14
|
+
| string_declaration
|
|
15
|
+
| flag_declaration
|
|
16
|
+
|
|
17
|
+
number_declaration: ID _COLON _NUMBER_TYPE ["(" [signed_number] "," [signed_number] ")"] ["=" signed_number] _NEWLINE
|
|
18
|
+
string_declaration: ID _COLON _STRING_TYPE ["=" STRING] _NEWLINE
|
|
19
|
+
flag_declaration: ID _COLON _FLAG_TYPE ["=" bool_literal] _NEWLINE
|
|
20
|
+
?bool_literal: TRUE
|
|
21
|
+
| FALSE
|
|
22
|
+
|
|
23
|
+
node: node_start body node_end
|
|
24
|
+
node_start: _NEWLINE* _NODESTART ID _NEWLINE
|
|
25
|
+
node_end: _NODEEND _NEWLINE*
|
|
26
|
+
|
|
27
|
+
body: statement*
|
|
28
|
+
|
|
29
|
+
?statement: line_statement
|
|
30
|
+
| block_statement
|
|
31
|
+
| blank_line
|
|
32
|
+
|
|
33
|
+
?line_statement: narration_line
|
|
34
|
+
| character_line
|
|
35
|
+
| set
|
|
36
|
+
| increase
|
|
37
|
+
| decrease
|
|
38
|
+
| setflag
|
|
39
|
+
| clearflag
|
|
40
|
+
| command
|
|
41
|
+
| goto
|
|
42
|
+
| finish
|
|
43
|
+
| continue
|
|
44
|
+
|
|
45
|
+
?block_statement: if_block
|
|
46
|
+
| option_block
|
|
47
|
+
| random_block
|
|
48
|
+
| match_block
|
|
49
|
+
|
|
50
|
+
?inline_statement: _COLON line_statement
|
|
51
|
+
|
|
52
|
+
inline_goto: _ARROW ID _NEWLINE
|
|
53
|
+
|
|
54
|
+
?action: (inline_statement | inline_goto | _NEWLINE body)
|
|
55
|
+
|
|
56
|
+
?inline_action: (inline_statement | inline_goto)
|
|
57
|
+
|
|
58
|
+
// =====================
|
|
59
|
+
// Lines / Statements
|
|
60
|
+
// =====================
|
|
61
|
+
|
|
62
|
+
narration_line: TEXT _NEWLINE
|
|
63
|
+
character_line: variable _COLON TEXT _NEWLINE
|
|
64
|
+
| _CHARACTER_MARKER CHARACTER_NAME _COLON TEXT _NEWLINE -> named_character_line
|
|
65
|
+
blank_line: _NEWLINE
|
|
66
|
+
|
|
67
|
+
set: _SET variable "=" expression _NEWLINE
|
|
68
|
+
increase: _INC variable [signed_number] _NEWLINE
|
|
69
|
+
decrease: _DEC variable [signed_number] _NEWLINE
|
|
70
|
+
setflag: _SETFLAG variable _NEWLINE
|
|
71
|
+
clearflag: _CLEARFLAG variable _NEWLINE
|
|
72
|
+
|
|
73
|
+
goto: _GOTO ID _NEWLINE
|
|
74
|
+
|
|
75
|
+
finish: _FINISH _NEWLINE
|
|
76
|
+
|
|
77
|
+
command: _COMMAND ID [_COLON TEXT] _NEWLINE
|
|
78
|
+
|
|
79
|
+
continue: _CONTINUE STRING (inline_action | _NEWLINE) -> continue_
|
|
80
|
+
|
|
81
|
+
// =====================
|
|
82
|
+
// If Block
|
|
83
|
+
// =====================
|
|
84
|
+
|
|
85
|
+
if_block: if_ [elif_list] [else_] _ENDIF _NEWLINE
|
|
86
|
+
if_: _IF condition action
|
|
87
|
+
elif_: _ELIF condition action
|
|
88
|
+
else_: _ELSE action
|
|
89
|
+
elif_list: elif_+
|
|
90
|
+
|
|
91
|
+
// =====================
|
|
92
|
+
// Option Block
|
|
93
|
+
// =====================
|
|
94
|
+
|
|
95
|
+
option_block: _OPTIONS _NEWLINE option_item+ _END_OPTIONS _NEWLINE
|
|
96
|
+
?option_item: option | hint_option
|
|
97
|
+
option: _OPTION [_LBRACKET condition _RBRACKET] STRING action
|
|
98
|
+
hint_option: _HINT_OPTION [_LBRACKET condition _RBRACKET] STRING _NEWLINE
|
|
99
|
+
|
|
100
|
+
// =====================
|
|
101
|
+
// Random Block
|
|
102
|
+
// =====================
|
|
103
|
+
|
|
104
|
+
random_block: _RANDOM _NEWLINE case+ _END_RANDOM _NEWLINE
|
|
105
|
+
case: _CASE [_LBRACKET condition _RBRACKET] [term] action
|
|
106
|
+
|
|
107
|
+
// =====================
|
|
108
|
+
// Match Block
|
|
109
|
+
// =====================
|
|
110
|
+
|
|
111
|
+
match_block: _MATCH [MATCH_MODIFIER] _NEWLINE when+ _END_MATCH _NEWLINE
|
|
112
|
+
when: _WHEN condition action
|
|
113
|
+
|
|
114
|
+
// =====================
|
|
115
|
+
// Expressions
|
|
116
|
+
// =====================
|
|
117
|
+
|
|
118
|
+
condition: expression
|
|
119
|
+
|
|
120
|
+
?expression: or_expr
|
|
121
|
+
|
|
122
|
+
?or_expr: and_expr
|
|
123
|
+
| or_expr _OR and_expr -> or_
|
|
124
|
+
|
|
125
|
+
?and_expr: not_expr
|
|
126
|
+
| and_expr _AND not_expr -> and_
|
|
127
|
+
|
|
128
|
+
?not_expr: _NOT not_expr -> not_
|
|
129
|
+
| comparison
|
|
130
|
+
|
|
131
|
+
?comparison: term
|
|
132
|
+
| term COMP_OP term -> compare
|
|
133
|
+
|
|
134
|
+
?term: sum
|
|
135
|
+
|
|
136
|
+
?sum: product
|
|
137
|
+
| sum "+" product -> add
|
|
138
|
+
| sum "-" product -> sub
|
|
139
|
+
|
|
140
|
+
?product: unary
|
|
141
|
+
| product "*" unary -> mul
|
|
142
|
+
| product "/" unary -> div
|
|
143
|
+
|
|
144
|
+
?unary: atom
|
|
145
|
+
| "-" unary -> neg
|
|
146
|
+
|
|
147
|
+
?atom: literal
|
|
148
|
+
| variable
|
|
149
|
+
| "(" expression ")"
|
|
150
|
+
|
|
151
|
+
variable: _VARIABLEMARKER ID
|
|
152
|
+
|
|
153
|
+
?signed_number: NUMBER
|
|
154
|
+
| "-" NUMBER -> negative_number
|
|
155
|
+
| "+" NUMBER
|
|
156
|
+
|
|
157
|
+
?literal: NUMBER
|
|
158
|
+
| STRING
|
|
159
|
+
| TRUE
|
|
160
|
+
| FALSE
|
|
161
|
+
|
|
162
|
+
// =====================
|
|
163
|
+
// Tokens
|
|
164
|
+
// =====================
|
|
165
|
+
|
|
166
|
+
TEXT: /(?![ \t]*[@#$>])[ \t]*\S[^\n]*/
|
|
167
|
+
CHARACTER_NAME: /[A-Za-z0-9][A-Za-z0-9 ]*/
|
|
168
|
+
COMP_OP: "==" | "!=" | ">" | "<" | ">=" | "<="
|
|
169
|
+
TRUE: "true"
|
|
170
|
+
FALSE: "false"
|
|
171
|
+
|
|
172
|
+
_NODESTART: "@node"
|
|
173
|
+
_NODEEND: "@endnode"
|
|
174
|
+
|
|
175
|
+
_ENV: "@env"
|
|
176
|
+
_ENDENV: "@endenv"
|
|
177
|
+
_NUMBER_TYPE: "number"
|
|
178
|
+
_STRING_TYPE: "string"
|
|
179
|
+
_FLAG_TYPE: "flag"
|
|
180
|
+
|
|
181
|
+
_SET: "@set"
|
|
182
|
+
_INC: "@increase"
|
|
183
|
+
_DEC: "@decrease"
|
|
184
|
+
_SETFLAG: "@setflag"
|
|
185
|
+
_CLEARFLAG: "@clearflag"
|
|
186
|
+
_GOTO: "@goto"
|
|
187
|
+
_FINISH: "@finish"
|
|
188
|
+
_COMMAND: /@(?!(node|endnode|env|endenv|set|increase|decrease|setflag|clearflag|goto|finish|if|elif|else|endif|options|option|hint|endoptions|continue|random|case|endrandom|match|when|endmatch)\b)/
|
|
189
|
+
_CONTINUE: "@continue"
|
|
190
|
+
|
|
191
|
+
_IF: "@if"
|
|
192
|
+
_ELIF: "@elif"
|
|
193
|
+
_ELSE: "@else"
|
|
194
|
+
_ENDIF: "@endif"
|
|
195
|
+
|
|
196
|
+
_OPTIONS: "@options"
|
|
197
|
+
_END_OPTIONS: "@endoptions"
|
|
198
|
+
_OPTION: "@option"
|
|
199
|
+
_HINT_OPTION: "@hint"
|
|
200
|
+
|
|
201
|
+
_RANDOM: "@random"
|
|
202
|
+
_CASE: "@case"
|
|
203
|
+
_END_RANDOM: "@endrandom"
|
|
204
|
+
|
|
205
|
+
_MATCH: "@match"
|
|
206
|
+
MATCH_MODIFIER: "first" | "last" | "all"
|
|
207
|
+
_WHEN: "@when"
|
|
208
|
+
_END_MATCH: "@endmatch"
|
|
209
|
+
|
|
210
|
+
_NOT: "not"
|
|
211
|
+
_AND: "and"
|
|
212
|
+
_OR: "or"
|
|
213
|
+
_VARIABLEMARKER: "$"
|
|
214
|
+
_CHARACTER_MARKER: ">"
|
|
215
|
+
|
|
216
|
+
_COLON: ":"
|
|
217
|
+
_LBRACKET: "["
|
|
218
|
+
_RBRACKET: "]"
|
|
219
|
+
_ARROW: "->"
|
|
220
|
+
|
|
221
|
+
// =====================
|
|
222
|
+
// Imports
|
|
223
|
+
// =====================
|
|
224
|
+
|
|
225
|
+
%import common.CNAME -> ID
|
|
226
|
+
%import common.NUMBER
|
|
227
|
+
%import common.ESCAPED_STRING -> STRING
|
|
228
|
+
%import common.WS_INLINE
|
|
229
|
+
|
|
230
|
+
// =====================
|
|
231
|
+
// Ignored
|
|
232
|
+
// =====================
|
|
233
|
+
|
|
234
|
+
// A statement terminator is one or more physical newlines, and it absorbs any
|
|
235
|
+
// trailing comments and blank lines. This lets `# ...` sit at the end of a line
|
|
236
|
+
// (inline comment) or on its own line inside a block without leaving a stray
|
|
237
|
+
// _NEWLINE that block rules like @options/@random/@match can't consume.
|
|
238
|
+
COMMENT: /#[^\n]*/
|
|
239
|
+
_NEWLINE: ( /\r?\n[\t ]*/ | COMMENT )+
|
|
240
|
+
%ignore COMMENT
|
|
241
|
+
%ignore WS_INLINE
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: weavly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compiler for the Weavly dialog scripting language
|
|
5
|
+
Author: Weavly contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/weavly-lang/weavly-compiler
|
|
8
|
+
Project-URL: Documentation, https://weavly-lang.github.io/weavly-docs/
|
|
9
|
+
Project-URL: Issues, https://github.com/weavly-lang/weavly-compiler/issues
|
|
10
|
+
Keywords: weavly,dialog,compiler,godot,interactive-fiction
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Games/Entertainment
|
|
20
|
+
Classifier: Topic :: Software Development :: Compilers
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: lark>=1.3.1
|
|
25
|
+
Requires-Dist: typer>=0.21.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Weavly Compiler
|
|
29
|
+
|
|
30
|
+
Compiler for the Weavly dialog scripting language. Parses `.wvl` files and compiles them to JSON for the Weavly Godot runtime.
|
|
31
|
+
|
|
32
|
+
Language documentation: https://weavly-lang.github.io/weavly-docs/
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install weavly
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
uv installs a suitable Python if needed and puts `weavly` on your PATH (run `uv tool update-shell` if it isn't). Upgrade with `uv tool upgrade weavly`.
|
|
43
|
+
|
|
44
|
+
With Python 3.11+ already installed, `pipx install weavly` works too.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
weavly init my-project # creates my-project/src/nodes.wvl
|
|
50
|
+
cd my-project
|
|
51
|
+
weavly build # compiles src/**/*.wvl into build/
|
|
52
|
+
weavly build --pretty # same, with indented JSON
|
|
53
|
+
weavly --version # installed compiler version
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`weavly init` without a name sets up `src/` in the current directory.
|
|
57
|
+
|
|
58
|
+
The build writes:
|
|
59
|
+
|
|
60
|
+
- `build/<path>.wvl.json` for each source file, containing its nodes
|
|
61
|
+
- `build/env.json` with every `@env` declaration in the project
|
|
62
|
+
|
|
63
|
+
Syntax errors, duplicate variable declarations, number declarations whose min, max or default don't fit together, duplicate node ids and `@goto` targets with no matching node fail the build with exit code 1. A failed build leaves the previous `build/` untouched.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/weavly-lang/weavly-compiler.git
|
|
69
|
+
cd weavly-compiler
|
|
70
|
+
uv sync --group dev
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow and release steps.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
weavly/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
2
|
+
weavly/cli.py,sha256=ULJx11l3pjQ5rPKRrvKtgZmNCVM9AMhJWil2usqp1xA,2031
|
|
3
|
+
weavly/reporting.py,sha256=yKcxVTjaWEODB418QkuMOdbGfC4It-b1_2Obo9nLm4U,759
|
|
4
|
+
weavly/parsing/__init__.py,sha256=LtCR23zwiCxtey4Wup-s4lKnpx_h2IPxdHURXu5_XUs,172
|
|
5
|
+
weavly/parsing/parser.py,sha256=nqLLOpbQwQ7BGltVTKmMZIxrZcz1Lh-rfpA2zwHFJJc,8402
|
|
6
|
+
weavly/parsing/syntax_errors.py,sha256=PisMPRw_0Z7Luw8Ar46jTczwRsZbLCfpAB6sHM_dlf8,4542
|
|
7
|
+
weavly/parsing/wvl_transformer.py,sha256=fICcNiBNUs2bqlEe3QycUKvCC0XciU-T-uDW6TvnNUI,9160
|
|
8
|
+
weavly/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
weavly/resources/wvl-grammar.lark,sha256=UPEuD6lovJ0S1WgdiXOhF4NyjPjpDsrg1GZ0ZASJjrA,5278
|
|
10
|
+
weavly-0.1.0.dist-info/licenses/LICENSE,sha256=kudIsspZciiruGid7hQWUAHRy6I8xqnA_a6PlbLMaI4,1076
|
|
11
|
+
weavly-0.1.0.dist-info/METADATA,sha256=x9v5143FBUFFIBGdjD52tv9Bs4Vg3LCcIQwXT8Lmwzc,2619
|
|
12
|
+
weavly-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
weavly-0.1.0.dist-info/entry_points.txt,sha256=iBG6cl4CxX2yrE6juoOoDphXIoa1QqN8J4f8rlWDjmg,42
|
|
14
|
+
weavly-0.1.0.dist-info/top_level.txt,sha256=FvfZyzx984EKi00-oQLLf03sX3v0RFcWjWMemtQPkBA,7
|
|
15
|
+
weavly-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Weavly contributors
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
weavly
|