Skip to content

Commit e7bd7bf

Browse files
feat: mecha error handling
1 parent 265dc74 commit e7bd7bf

12 files changed

Lines changed: 170 additions & 21 deletions

File tree

packages/mecha/src/mecha/api.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
from pydantic import BaseModel, field_validator
5555
from tokenstream import InvalidSyntax, Preprocessor, TokenStream, set_location
5656

57-
from .ast import AstLiteral, AstNode, AstRoot
57+
from .ast import AstError, AstLiteral, AstNode, AstRoot
5858
from .config import CommandTree
5959
from .database import (
6060
CompilationDatabase,
@@ -396,7 +396,7 @@ def parse(
396396
filename=str(filename) if filename else None,
397397
file=source,
398398
)
399-
raise DiagnosticError(DiagnosticCollection([set_location(d, exc)])) from exc
399+
diagnostics.append(set_location(d, exc))
400400
else:
401401
if self.cache and filename and cache_miss:
402402
try:
@@ -407,6 +407,24 @@ def parse(
407407
pass
408408
return ast
409409

410+
if len(diagnostics) > 0:
411+
if self.cache and filename and cache_miss:
412+
self.cache.invalidate_changes(self.directory / filename)
413+
414+
raise DiagnosticError(DiagnosticCollection(diagnostics))
415+
416+
def parse_stream(
417+
self,
418+
multiline: bool | None,
419+
provide: JsonDict | None,
420+
parser: str,
421+
stream: TokenStream,
422+
):
423+
with self.prepare_token_stream(stream, multiline=multiline):
424+
with stream.provide(**provide or {}):
425+
ast = delegate(parser, stream)
426+
return ast
427+
410428
@overload
411429
def compile(
412430
self,

packages/mecha/src/mecha/ast.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"AstNode",
55
"AstChildren",
66
"AstRoot",
7+
"AstError",
78
"AstCommand",
89
"AstCommandSentinel",
910
"AstString",
@@ -119,7 +120,9 @@
119120
from nbtlib import Byte, ByteArray, Compound, CompoundMatch, Double, Int, IntArray
120121
from nbtlib import List as ListTag
121122
from nbtlib import ListIndex, LongArray, NamedKey, Numeric, Path, String
122-
from tokenstream import UNKNOWN_LOCATION, SourceLocation, set_location
123+
from tokenstream import UNKNOWN_LOCATION, InvalidSyntax, SourceLocation, set_location
124+
125+
from mecha.error import MechaError
123126

124127
from .utils import string_to_number
125128

@@ -266,11 +269,18 @@ class AstChildren(AbstractChildren[AstNodeType]):
266269
class AstRoot(AstNode):
267270
"""Ast root node."""
268271

269-
commands: AstChildren["AstCommand"] = required_field()
272+
commands: AstChildren["AstCommand|AstError"] = required_field()
270273

271274
parser = "root"
272275

273276

277+
@dataclass(frozen=True, slots=True)
278+
class AstError(AstNode):
279+
"""Ast error node."""
280+
281+
error: InvalidSyntax = required_field()
282+
283+
274284
@dataclass(frozen=True, slots=True)
275285
class AstCommand(AstNode):
276286
"""Ast command node."""

packages/mecha/src/mecha/contrib/bake_macros.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from mecha import (
3232
AstChildren,
3333
AstCommand,
34+
AstError,
3435
AstMacroLine,
3536
AstMacroLineText,
3637
AstMacroLineVariable,
@@ -135,10 +136,14 @@ class MacroBaker(Visitor):
135136
def bake_macros(self, node: AstRoot):
136137
invocation = self.invocations.get(self.database.current)
137138

138-
result: List[AstCommand] = []
139+
result: List[AstCommand|AstError] = []
139140
modified = False
140141

141142
for command in node.commands:
143+
if isinstance(command, AstError):
144+
result.append(command)
145+
continue
146+
142147
if isinstance(command, AstMacroLine) and invocation:
143148
baked_macro_line = self.bake_macro_line(command, invocation)
144149
modified |= baked_macro_line is not command

packages/mecha/src/mecha/contrib/inline_function_tag.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from mecha import (
1818
AstChildren,
1919
AstCommand,
20+
AstError,
2021
AstResourceLocation,
2122
AstRoot,
2223
CommandTree,
@@ -66,9 +67,13 @@ class InlineFunctionTagHandler(Visitor):
6667
@rule(AstRoot)
6768
def inline_function_tag(self, node: AstRoot):
6869
changed = False
69-
commands: List[AstCommand] = []
70+
commands: List[AstCommand|AstError] = []
7071

7172
for command in node.commands:
73+
if isinstance(command, AstError):
74+
commands.append(command)
75+
continue
76+
7277
if command.identifier == "function:tag:name" and isinstance(
7378
tag := command.arguments[0], AstResourceLocation
7479
):

packages/mecha/src/mecha/contrib/nested_resources.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from mecha import (
3030
AstChildren,
3131
AstCommand,
32+
AstError,
3233
AstJson,
3334
AstNode,
3435
AstResourceLocation,
@@ -217,9 +218,13 @@ class NestedResourcesTransformer(MutatingReducer):
217218
@rule(AstRoot)
218219
def nested_resources(self, node: AstRoot):
219220
changed = False
220-
commands: List[AstCommand] = []
221+
commands: List[AstCommand|AstError] = []
221222

222223
for command in node.commands:
224+
if isinstance(command, AstError):
225+
commands.append(command)
226+
continue
227+
223228
if file_type := self.nested_resource_identifiers.get(command.identifier):
224229
name, content = command.arguments
225230

packages/mecha/src/mecha/contrib/nesting.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from mecha import (
2323
AstChildren,
2424
AstCommand,
25+
AstError,
2526
AstResourceLocation,
2627
AstRoot,
2728
CommandTree,
@@ -34,6 +35,7 @@
3435
rule,
3536
)
3637
from mecha.contrib.nested_location import NestedLocationResolver
38+
from mecha.parse import parse_root_item
3739

3840

3941
class NestingOptions(BaseModel):
@@ -81,8 +83,8 @@ def parse_nested_root(stream: TokenStream) -> AstRoot:
8183

8284
level, command_level = stream.indentation[-2:]
8385

84-
commands: List[AstCommand] = []
85-
86+
errors: list[InvalidSyntax] = []
87+
commands: List[AstCommand|AstError] = []
8688
with (
8789
stream.intercept("newline"),
8890
stream.provide(
@@ -91,7 +93,11 @@ def parse_nested_root(stream: TokenStream) -> AstRoot:
9193
),
9294
):
9395
while True:
94-
commands.append(delegate("root_item", stream))
96+
97+
result = parse_root_item(stream, errors, colon=True)
98+
99+
if result is not None:
100+
commands.append(result)
95101

96102
# The command parser consumes the trailing newline so we need to rewind
97103
# to be able to use "consume_line_continuation()".
@@ -101,7 +107,6 @@ def parse_nested_root(stream: TokenStream) -> AstRoot:
101107
with stream.provide(multiline=True, line_indentation=level):
102108
if not consume_line_continuation(stream):
103109
break
104-
105110
node = AstRoot(commands=AstChildren(commands))
106111
return set_location(node, commands[0], commands[-1])
107112

@@ -179,6 +184,9 @@ def nesting_execute_commands(self, node: AstCommand):
179184

180185
single_command = None
181186
for command in root.commands:
187+
if isinstance(command, AstError):
188+
continue
189+
182190
if command.compile_hints.get("skip_execute_inline_single_command"):
183191
continue
184192
if single_command is None:
@@ -270,12 +278,20 @@ def nesting_schedule_function(self, node: AstCommand):
270278
@rule(AstRoot)
271279
def nesting(self, node: AstRoot):
272280
changed = False
273-
commands: List[AstCommand] = []
281+
commands: List[AstCommand|AstError] = []
274282

275283
for command in node.commands:
284+
if isinstance(command, AstError):
285+
commands.append(command)
286+
continue
287+
276288
if command.identifier in self.identifier_map:
277289
result = yield from self.handle_function(command, top_level=True)
278290
commands.extend(result)
291+
292+
if len(result) == 1 and result[0] is command:
293+
continue
294+
279295
changed = True
280296
continue
281297

@@ -294,6 +310,10 @@ def nesting(self, node: AstRoot):
294310
if expand:
295311
changed = True
296312
for nested_command in cast(AstRoot, expand.arguments[0]).commands:
313+
if isinstance(nested_command, AstError):
314+
commands.append(nested_command)
315+
continue
316+
297317
if nested_command.identifier == "execute:subcommand":
298318
expansion = cast(AstCommand, nested_command.arguments[0])
299319
else:
@@ -321,7 +341,7 @@ def handle_function(
321341
self,
322342
node: AstCommand,
323343
top_level: bool = False,
324-
) -> Generator[Diagnostic, None, List[AstCommand]]:
344+
) -> Generator[Diagnostic, None, List[AstCommand|AstError]]:
325345
name, *args, root = node.arguments
326346

327347
if isinstance(name, AstResourceLocation) and isinstance(root, AstRoot):

packages/mecha/src/mecha/contrib/statistics.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mecha import (
2020
AstCommand,
2121
AstCommandSentinel,
22+
AstError,
2223
AstMacroLine,
2324
AstMacroLineText,
2425
AstMacroLineVariable,
@@ -110,9 +111,9 @@ def root(self, node: AstRoot):
110111
self.stats.function_count += 1
111112

112113
for command in node.commands:
113-
if isinstance(command, AstCommandSentinel):
114+
if isinstance(command, (AstCommandSentinel, AstError)):
114115
continue
115-
116+
116117
behind_execute = False
117118

118119
while command.identifier.startswith("execute"):

packages/mecha/src/mecha/parse.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,15 @@
9191
from beet import LATEST_MINECRAFT_VERSION
9292
from beet.core.utils import VersionNumber, split_version
9393
from nbtlib import Byte, Double, Float, Int, Long, OutOfRange, Short, String
94-
from tokenstream import InvalidSyntax, SourceLocation, TokenStream, set_location
94+
from tokenstream import (
95+
InvalidSyntax,
96+
SourceLocation,
97+
SyntaxRules,
98+
TokenStream,
99+
UnexpectedToken,
100+
set_location,
101+
Token,
102+
)
95103

96104
from .ast import (
97105
AstAdvancementPredicate,
@@ -108,6 +116,7 @@
108116
AstDustColorTransitionParticleParameters,
109117
AstDustParticleParameters,
110118
AstEntityAnchor,
119+
AstError,
111120
AstFallingDustParticleParameters,
112121
AstGamemode,
113122
AstGreedy,
@@ -638,6 +647,13 @@ def __init__(self, parser: str):
638647
self.parser = parser
639648

640649

650+
class InvalidSyntaxCollection(MechaError):
651+
errors: list[InvalidSyntax]
652+
653+
def __init__(self, errors: list[InvalidSyntax]):
654+
self.errors = errors
655+
656+
641657
@overload
642658
def delegate(parser: str) -> Parser: ...
643659

@@ -681,6 +697,7 @@ def consume_line_continuation(stream: TokenStream) -> bool:
681697
return False
682698

683699

700+
# TODO: Attempt to move error recovery into AstCommand's parser
684701
def parse_root(stream: TokenStream) -> AstRoot:
685702
"""Parse root."""
686703
start = stream.peek()
@@ -689,20 +706,59 @@ def parse_root(stream: TokenStream) -> AstRoot:
689706
node = AstRoot(commands=AstChildren[AstCommand]())
690707
return set_location(node, SourceLocation(0, 1, 1))
691708

692-
commands: List[AstCommand] = []
709+
errors: List[InvalidSyntax] = []
710+
commands: List[AstCommand | AstError] = []
693711

694712
with stream.ignore("comment"):
695713
for _ in stream.peek_until():
696714
if stream.get("newline"):
697715
continue
698716
if stream.get("eof"):
699717
break
700-
commands.append(delegate("root_item", stream))
701718

702-
node = AstRoot(commands=AstChildren(commands))
703-
return set_location(node, start, stream.current)
719+
result = parse_root_item(stream, errors)
720+
if result is not None:
721+
commands.append(result)
722+
723+
children = AstChildren(commands)
724+
725+
node = AstRoot(commands=children)
726+
727+
if stream.index < 0:
728+
end_location = SourceLocation(1, 0, 0)
729+
else:
730+
end_location = stream.current.end_location
704731

732+
return set_location(node, start, end_location)
705733

734+
def consume_error(stream: TokenStream, errors: list[InvalidSyntax]):
735+
next = stream.peek()
736+
with stream.syntax(unknown=r"."):
737+
while next := stream.peek():
738+
stream.expect()
739+
if next.value == "\n":
740+
break
741+
node = AstError(errors[-1].location, errors[-1].end_location, errors[-1])
742+
return node
743+
744+
745+
746+
def parse_root_item(stream: TokenStream, errors: list[InvalidSyntax], colon: bool = False):
747+
748+
with stream.checkpoint() as commit:
749+
try:
750+
command: AstCommand = delegate("root_item", stream)
751+
commit()
752+
return command
753+
except InvalidSyntax as exc:
754+
errors.append(exc)
755+
756+
if commit.rollback:
757+
if colon:
758+
with stream.syntax(colon=r":"):
759+
return consume_error(stream, errors)
760+
return consume_error(stream, errors)
761+
706762
def parse_command(stream: TokenStream) -> AstCommand:
707763
"""Parse command."""
708764
spec = get_stream_spec(stream)

0 commit comments

Comments
 (0)