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 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()}")
@@ -0,0 +1,7 @@
1
+ from .parser import WVL_BUILD_EXTENSION, WVL_SOURCE_EXTENSION, build_all_files
2
+
3
+ __all__ = [
4
+ "build_all_files",
5
+ "WVL_SOURCE_EXTENSION",
6
+ "WVL_BUILD_EXTENSION",
7
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ weavly = weavly.cli:app
@@ -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