bolt-anchors 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.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.3
2
+ Name: bolt-anchors
3
+ Version: 0.1.0
4
+ Summary: Name resource locations and build nested paths in bolt
5
+ Requires-Dist: beet>=0.119.0
6
+ Requires-Dist: bolt>=0.51.0
7
+ Requires-Dist: mecha>=0.106.0
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Bolt Anchors
12
+
13
+ > Name resource locations and build nested paths in [bolt](https://github.com/mcbeet/beet/tree/main/packages/bolt).
14
+
15
+ Bolt anchors let you bind a resource declaration to an identifier with the `as`
16
+ clause. The anchor can then be used as a value or extended with `/` to build
17
+ nested resource locations.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install bolt-anchors
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Add the plugin to your `beet.yml`/`beet.json` after `bolt`.
28
+
29
+ ```yaml
30
+ pipeline:
31
+ - mecha
32
+
33
+ require:
34
+ - bolt
35
+ - bolt_anchors
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ Bind an anchor when declaring any resource. The anchor holds the fully resolved
41
+ resource location of the declaration.
42
+
43
+ ```mcfunction
44
+ append function ~/foo as foo:
45
+ print(foo)
46
+ function foo / "bar":
47
+ say foo bar
48
+ ```
49
+
50
+ `foo` resolves to `namespace:foo/foo` (relative to the current file and the
51
+ enclosing declarations) and `foo / "bar"` extends it into a nested resource
52
+ location. Anchors work for any resource type and any path depth.
53
+
54
+ ```mcfunction
55
+ loot_table ./technical/loot as LOOT {}
56
+ print(LOOT)
57
+ ```
58
+
59
+ Anchors can be used as values, interpolated into strings, and reused when
60
+ declaring or calling other resources.
61
+
62
+ ```mcfunction
63
+ function ~/utils as utils:
64
+ function utils / "math" / "add":
65
+ scoreboard players add @s points 1
66
+
67
+ advancement ./tech/root as ROOT {}
68
+ predicate ROOT / "check" {}
69
+ say f"prefix is {utils}"
70
+ function utils / "math" / "add"
71
+ ```
72
+
73
+ ### Moving the path scope
74
+
75
+ The `anchor` command changes the current path scope without creating a resource.
76
+ Nested locations and anchors inside its block resolve against the new location.
77
+
78
+ ```mcfunction
79
+ anchor demo:foo as foo:
80
+ function ~/bar as bar:
81
+ print(bar) # demo:foo/bar
82
+ function foo / "baz" as baz:
83
+ print(baz) # demo:foo/baz
84
+ ```
85
+
86
+ The block itself emits no file. Commands and statements inside an `anchor` block
87
+ are treated like regular top-level code and are inlined into the current file,
88
+ while any resource declarations create files relative to the new scope.
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ uv sync --dev
94
+ uv run pytest
95
+ uv run ruff check
96
+ ```
@@ -0,0 +1,86 @@
1
+ # Bolt Anchors
2
+
3
+ > Name resource locations and build nested paths in [bolt](https://github.com/mcbeet/beet/tree/main/packages/bolt).
4
+
5
+ Bolt anchors let you bind a resource declaration to an identifier with the `as`
6
+ clause. The anchor can then be used as a value or extended with `/` to build
7
+ nested resource locations.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install bolt-anchors
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Add the plugin to your `beet.yml`/`beet.json` after `bolt`.
18
+
19
+ ```yaml
20
+ pipeline:
21
+ - mecha
22
+
23
+ require:
24
+ - bolt
25
+ - bolt_anchors
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Bind an anchor when declaring any resource. The anchor holds the fully resolved
31
+ resource location of the declaration.
32
+
33
+ ```mcfunction
34
+ append function ~/foo as foo:
35
+ print(foo)
36
+ function foo / "bar":
37
+ say foo bar
38
+ ```
39
+
40
+ `foo` resolves to `namespace:foo/foo` (relative to the current file and the
41
+ enclosing declarations) and `foo / "bar"` extends it into a nested resource
42
+ location. Anchors work for any resource type and any path depth.
43
+
44
+ ```mcfunction
45
+ loot_table ./technical/loot as LOOT {}
46
+ print(LOOT)
47
+ ```
48
+
49
+ Anchors can be used as values, interpolated into strings, and reused when
50
+ declaring or calling other resources.
51
+
52
+ ```mcfunction
53
+ function ~/utils as utils:
54
+ function utils / "math" / "add":
55
+ scoreboard players add @s points 1
56
+
57
+ advancement ./tech/root as ROOT {}
58
+ predicate ROOT / "check" {}
59
+ say f"prefix is {utils}"
60
+ function utils / "math" / "add"
61
+ ```
62
+
63
+ ### Moving the path scope
64
+
65
+ The `anchor` command changes the current path scope without creating a resource.
66
+ Nested locations and anchors inside its block resolve against the new location.
67
+
68
+ ```mcfunction
69
+ anchor demo:foo as foo:
70
+ function ~/bar as bar:
71
+ print(bar) # demo:foo/bar
72
+ function foo / "baz" as baz:
73
+ print(baz) # demo:foo/baz
74
+ ```
75
+
76
+ The block itself emits no file. Commands and statements inside an `anchor` block
77
+ are treated like regular top-level code and are inlined into the current file,
78
+ while any resource declarations create files relative to the new scope.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ uv sync --dev
84
+ uv run pytest
85
+ uv run ruff check
86
+ ```
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "bolt-anchors"
3
+ version = "0.1.0"
4
+ description = "Name resource locations and build nested paths in bolt"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "beet>=0.119.0",
9
+ "bolt>=0.51.0",
10
+ "mecha>=0.106.0",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "lectern>=0.35.0",
16
+ "pytest>=9.0.2",
17
+ "pytest-insta>=0.4.1",
18
+ "python-semantic-release>=9.21.0",
19
+ "ruff>=0.14.13",
20
+ ]
21
+
22
+ [tool.semantic_release]
23
+ major_on_zero = false
24
+ allow_zero_version = true
25
+ build_command = "uv build"
26
+ version_variables = ["src/bolt_anchors/__init__.py:__version__"]
27
+ version_toml = ["pyproject.toml:project.version"]
28
+ commit_author = "github-actions <action@github.com>"
29
+
30
+ [tool.semantic_release.publish]
31
+ dist_glob_patterns = ["dist/*"]
32
+ upload_to_vcs_release = true
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.9.26,<0.10.0"]
36
+ build-backend = "uv_build"
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "bolt-anchors"
3
+ version = "0.1.0"
4
+ description = "Name resource locations and build nested paths in bolt"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "beet>=0.119.0",
9
+ "bolt>=0.51.0",
10
+ "mecha>=0.106.0",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "lectern>=0.35.0",
16
+ "pytest>=9.0.2",
17
+ "pytest-insta>=0.4.1",
18
+ "python-semantic-release>=9.21.0",
19
+ "ruff>=0.14.13",
20
+ ]
21
+
22
+ [tool.semantic_release]
23
+ major_on_zero = false
24
+ allow_zero_version = true
25
+ build_command = "uv build"
26
+
27
+ version_variables = ["src/bolt_anchors/__init__.py:__version__"]
28
+ version_toml = ["pyproject.toml:project.version"]
29
+ commit_author = "github-actions <action@github.com>"
30
+
31
+ [tool.semantic_release.publish]
32
+ # This ensures it uses the build_command defined above
33
+ dist_glob_patterns = ["dist/*"]
34
+ upload_to_vcs_release = true
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.9.26,<0.10.0"]
38
+ build-backend = "uv_build"
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .ast import AstAnchor as AstAnchor
4
+ from .plugin import beet_default as beet_default
@@ -0,0 +1,20 @@
1
+ __all__ = [
2
+ "AstAnchor",
3
+ ]
4
+
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from bolt import AstValue
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class AstAnchor(AstValue):
14
+ """Ast node representing a named resource location anchor.
15
+
16
+ The anchor holds the fully resolved resource location of the declaration it
17
+ was created from. When used as a value it behaves like a plain string.
18
+ """
19
+
20
+ value: Any = ""
@@ -0,0 +1,331 @@
1
+ __all__ = [
2
+ "ANCHOR_COMMAND_IDENTIFIER",
3
+ "AnchorBlockParser",
4
+ "AnchorCommandTransformer",
5
+ "AnchorContext",
6
+ "AnchorIdentifierParser",
7
+ "AnchorRootParser",
8
+ "ResourceNameParser",
9
+ ]
10
+
11
+
12
+ import re
13
+ from collections.abc import Iterator
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, field, replace
16
+ from typing import Any
17
+
18
+ from bolt.parse import IDENTIFIER_PATTERN
19
+ from bolt.pattern import STRING_PATTERN
20
+ from mecha import (
21
+ AstChildren,
22
+ AstCommand,
23
+ AstNode,
24
+ AstResourceLocation,
25
+ AstRoot,
26
+ CommandSpec,
27
+ CompilationDatabase,
28
+ MutatingReducer,
29
+ Parser,
30
+ get_stream_scope,
31
+ rule,
32
+ )
33
+ from mecha.contrib.nested_location import AstNestedLocation
34
+ from mecha.contrib.relative_location import resolve_relative_location
35
+ from mecha.utils import JsonQuoteHelper
36
+ from tokenstream import Token, TokenStream, set_location
37
+
38
+ from .ast import AstAnchor
39
+
40
+ ANCHOR_COMMAND_IDENTIFIER = "anchor:name:commands"
41
+
42
+ IDENTIFIER_REGEX = re.compile(IDENTIFIER_PATTERN)
43
+
44
+ # Children that indicate that the current resource location argument introduces
45
+ # a resource declaration with a body.
46
+ DECLARATION_CHILDREN = {"commands", "content"}
47
+
48
+
49
+ class AnchorContext:
50
+ """Tracks anchors and declaration roots while parsing a compilation unit."""
51
+
52
+ def __init__(self, database: CompilationDatabase):
53
+ self.database = database
54
+ self._unit: Any = None
55
+ self._bases: list[str] = []
56
+ self._anchored: list[bool] = []
57
+ self._scopes: list[dict[str, AstAnchor]] = [{}]
58
+ self._pending: tuple[tuple[str, ...], str, bool] | None = None
59
+
60
+ def reset_if_needed(self):
61
+ unit = self.database.current
62
+ if unit is not self._unit:
63
+ self._unit = unit
64
+ self._bases.clear()
65
+ self._anchored.clear()
66
+ self._scopes = [{}]
67
+ self._pending = None
68
+
69
+ def file_root(self) -> str | None:
70
+ unit = self.database.current
71
+ if unit is None:
72
+ return None
73
+ return self.database[unit].resource_location
74
+
75
+ def current_root(self) -> str | None:
76
+ if self._bases:
77
+ return self._bases[-1]
78
+ return self.file_root()
79
+
80
+ @contextmanager
81
+ def scope(self) -> Iterator[dict[str, AstAnchor]]:
82
+ self._scopes.append({})
83
+ try:
84
+ yield self._scopes[-1]
85
+ finally:
86
+ self._scopes.pop()
87
+
88
+ @contextmanager
89
+ def declaration(self, base: str, anchored: bool = False) -> Iterator[None]:
90
+ with self.scope():
91
+ self._bases.append(base)
92
+ self._anchored.append(anchored or self.is_anchored())
93
+ try:
94
+ yield
95
+ finally:
96
+ self._anchored.pop()
97
+ self._bases.pop()
98
+
99
+ def is_anchored(self) -> bool:
100
+ return any(self._anchored)
101
+
102
+ def bind(self, identifier: str, anchor: AstAnchor):
103
+ self._scopes[-1][identifier] = anchor
104
+
105
+ def lookup(self, identifier: str) -> AstAnchor | None:
106
+ for scope in reversed(self._scopes):
107
+ if anchor := scope.get(identifier):
108
+ return anchor
109
+ return None
110
+
111
+ def set_pending(self, name_scope: tuple[str, ...], base: str, anchored: bool):
112
+ self._pending = (name_scope, base, anchored)
113
+
114
+ def take_pending(self, name_scope: tuple[str, ...]) -> tuple[str, bool] | None:
115
+ pending = self._pending
116
+ if pending is None or pending[0] != name_scope:
117
+ return None
118
+ self._pending = None
119
+ return pending[1], pending[2]
120
+
121
+
122
+ def is_declaration_name(spec: CommandSpec, scope: tuple[str, ...]) -> bool:
123
+ """Return whether the given argument scope introduces a resource declaration."""
124
+ tree = spec.tree.get(scope)
125
+ if tree is None or not tree.children:
126
+ return False
127
+ return any(name in DECLARATION_CHILDREN for name in tree.children)
128
+
129
+
130
+ def resolve_nested_location(
131
+ node: AstNestedLocation, context: AnchorContext
132
+ ) -> AstResourceLocation:
133
+ """Resolve a nested location against the current declaration root."""
134
+ root = context.current_root()
135
+ if root is None:
136
+ raise node.emit_error(
137
+ ValueError("Can't resolve nested location without a root.")
138
+ )
139
+
140
+ namespace, resolved = resolve_relative_location(
141
+ node.path, root, include_root_file=True
142
+ )
143
+ resource_location = AstResourceLocation(
144
+ is_tag=node.is_tag, namespace=namespace, path=resolved
145
+ )
146
+ return set_location(resource_location, node)
147
+
148
+
149
+ def canonical_location(node: AstResourceLocation, context: AnchorContext) -> str:
150
+ """Return the fully resolved resource location of a name node."""
151
+ if isinstance(node, AstNestedLocation):
152
+ return resolve_nested_location(node, context).get_canonical_value()
153
+ return node.get_canonical_value()
154
+
155
+
156
+ ANCHOR_SYNTAX = {
157
+ "literal": None,
158
+ "identifier": IDENTIFIER_PATTERN,
159
+ "string": STRING_PATTERN,
160
+ "number": r"(?:0|[1-9]\d*)(?:\.\d+)?",
161
+ "slash": r"/",
162
+ }
163
+
164
+
165
+ def parse_anchor_path(
166
+ stream: TokenStream,
167
+ context: AnchorContext,
168
+ quote_helper: JsonQuoteHelper,
169
+ ) -> tuple[str, Token] | None:
170
+ """Parse an anchor followed by any number of path segments."""
171
+ with stream.syntax(**ANCHOR_SYNTAX):
172
+ token = stream.peek()
173
+
174
+ if token is None or not isinstance(token.value, str):
175
+ return None
176
+ if not IDENTIFIER_REGEX.fullmatch(token.value):
177
+ return None
178
+
179
+ anchor = context.lookup(token.value)
180
+ if anchor is None:
181
+ return None
182
+
183
+ start = stream.expect()
184
+ value = str(anchor.value)
185
+
186
+ while stream.get(("slash", "/")):
187
+ segment = stream.expect_any("string", "identifier", "number")
188
+ if segment.match("string"):
189
+ text = quote_helper.unquote_string(segment)
190
+ else:
191
+ text = segment.value
192
+ value = f"{value}/{text}"
193
+
194
+ return value, start
195
+
196
+
197
+ @dataclass
198
+ class ResourceNameParser:
199
+ """Parser for resource declaration names that supports anchors."""
200
+
201
+ parser: Parser
202
+ context: AnchorContext
203
+ spec: CommandSpec
204
+ quote_helper: JsonQuoteHelper = field(default_factory=JsonQuoteHelper)
205
+
206
+ def __call__(self, stream: TokenStream) -> AstNode:
207
+ self.context.reset_if_needed()
208
+
209
+ node = self.parse_anchor(stream)
210
+ if node is None:
211
+ node = self.parser(stream)
212
+
213
+ scope = get_stream_scope(stream)
214
+ if not is_declaration_name(self.spec, scope):
215
+ return node
216
+
217
+ anchored = scope[0] == "anchor"
218
+
219
+ if (anchored or self.context.is_anchored()) and isinstance(
220
+ node, AstNestedLocation
221
+ ):
222
+ node = resolve_nested_location(node, self.context)
223
+
224
+ base = canonical_location(node, self.context)
225
+
226
+ with stream.checkpoint() as commit:
227
+ if stream.get(("literal", "as")):
228
+ with stream.syntax(identifier=IDENTIFIER_PATTERN):
229
+ token = stream.expect("identifier")
230
+ self.context.bind(
231
+ token.value, set_location(AstAnchor(value=base), token)
232
+ )
233
+ commit()
234
+
235
+ self.context.set_pending(scope, base, anchored)
236
+ return node
237
+
238
+ def parse_anchor(self, stream: TokenStream) -> AstResourceLocation | None:
239
+ result = parse_anchor_path(stream, self.context, self.quote_helper)
240
+ if result is None:
241
+ return None
242
+
243
+ value, start = result
244
+ node = AstResourceLocation.from_value(value)
245
+ return set_location(node, start, stream.current)
246
+
247
+
248
+ @dataclass
249
+ class AnchorIdentifierParser:
250
+ """Parser for identifiers that resolves anchors to their resource location."""
251
+
252
+ parser: Parser
253
+ context: AnchorContext
254
+ quote_helper: JsonQuoteHelper = field(default_factory=JsonQuoteHelper)
255
+
256
+ def __call__(self, stream: TokenStream) -> AstNode:
257
+ self.context.reset_if_needed()
258
+
259
+ result = parse_anchor_path(stream, self.context, self.quote_helper)
260
+ if result is not None:
261
+ value, start = result
262
+ node = AstAnchor(value=value)
263
+ return set_location(node, start, stream.current)
264
+
265
+ return self.parser(stream)
266
+
267
+
268
+ @dataclass
269
+ class AnchorBlockParser:
270
+ """Parser that manages the anchor scope of a resource declaration body."""
271
+
272
+ parser: Parser
273
+ context: AnchorContext
274
+
275
+ def __call__(self, stream: TokenStream) -> Any:
276
+ self.context.reset_if_needed()
277
+
278
+ scope = get_stream_scope(stream)
279
+ pending = self.context.take_pending(scope[:-1])
280
+
281
+ if pending is None:
282
+ return self.parser(stream)
283
+
284
+ base, anchored = pending
285
+
286
+ with self.context.declaration(base, anchored):
287
+ return self.parser(stream)
288
+
289
+
290
+ @dataclass
291
+ class AnchorCommandTransformer(MutatingReducer):
292
+ """Inlines the body of `anchor` commands into the surrounding root."""
293
+
294
+ @rule(AstRoot)
295
+ def anchor_commands(self, node: AstRoot) -> AstRoot:
296
+ changed = False
297
+ commands: list[AstCommand] = []
298
+
299
+ for command in node.commands:
300
+ if (
301
+ isinstance(command, AstCommand)
302
+ and command.identifier == ANCHOR_COMMAND_IDENTIFIER
303
+ and command.arguments
304
+ and isinstance(body := command.arguments[-1], AstRoot)
305
+ ):
306
+ commands.extend(body.commands)
307
+ changed = True
308
+ continue
309
+
310
+ commands.append(command)
311
+
312
+ if changed:
313
+ return replace(node, commands=AstChildren(commands))
314
+
315
+ return node
316
+
317
+
318
+ @dataclass
319
+ class AnchorRootParser:
320
+ """Parser that inlines `anchor` commands right after parsing."""
321
+
322
+ parser: Parser
323
+ transformer: AnchorCommandTransformer = field(
324
+ default_factory=AnchorCommandTransformer
325
+ )
326
+
327
+ def __call__(self, stream: TokenStream) -> Any:
328
+ node = self.parser(stream)
329
+ if isinstance(node, AstRoot):
330
+ return self.transformer(node)
331
+ return node
@@ -0,0 +1,73 @@
1
+ from beet import Context
2
+ from bolt import Runtime
3
+ from mecha import Mecha
4
+
5
+ from .parse import (
6
+ AnchorBlockParser,
7
+ AnchorContext,
8
+ AnchorIdentifierParser,
9
+ AnchorRootParser,
10
+ ResourceNameParser,
11
+ )
12
+
13
+ __all__ = ["beet_default"]
14
+
15
+ RESOURCE_NAME_PARSERS = (
16
+ "command:argument:minecraft:function",
17
+ "command:argument:minecraft:resource_location",
18
+ )
19
+
20
+ BLOCK_PARSERS = (
21
+ "command:argument:mecha:nested_root",
22
+ "command:argument:mecha:nested_json",
23
+ )
24
+
25
+ # `anchor <resource_location> [as <identifier>]:` moves the path scope and can
26
+ # bind the location to an identifier without creating a resource.
27
+ ANCHOR_COMMANDS = {
28
+ "type": "root",
29
+ "children": {
30
+ "anchor": {
31
+ "type": "literal",
32
+ "children": {
33
+ "name": {
34
+ "type": "argument",
35
+ "parser": "minecraft:resource_location",
36
+ "children": {
37
+ "commands": {
38
+ "type": "argument",
39
+ "parser": "mecha:nested_root",
40
+ "executable": True,
41
+ }
42
+ },
43
+ }
44
+ },
45
+ }
46
+ },
47
+ }
48
+
49
+
50
+ def beet_default(ctx: Context):
51
+ ctx.inject(Runtime)
52
+ mc = ctx.inject(Mecha)
53
+
54
+ context = AnchorContext(mc.database)
55
+ spec = mc.spec
56
+
57
+ spec.add_commands(ANCHOR_COMMANDS)
58
+
59
+ for name in RESOURCE_NAME_PARSERS:
60
+ if parser := spec.parsers.get(name):
61
+ spec.parsers[name] = ResourceNameParser(parser, context, spec)
62
+
63
+ for name in BLOCK_PARSERS:
64
+ if parser := spec.parsers.get(name):
65
+ spec.parsers[name] = AnchorBlockParser(parser, context)
66
+
67
+ if parser := spec.parsers.get("bolt:identifier"):
68
+ spec.parsers["bolt:identifier"] = AnchorIdentifierParser(parser, context)
69
+
70
+ # Inline anchor bodies at parse time so that the cached ast already reflects
71
+ # the moved path scope and statements are evaluated like regular code.
72
+ if parser := spec.parsers.get("root"):
73
+ spec.parsers["root"] = AnchorRootParser(parser)