Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/mecha/src/mecha/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,14 @@ def compile(
self.database.enqueue(file_instance, initial_step)
except DiagnosticError as exc:
compilation_unit.diagnostics.extend(exc.diagnostics)
except UnicodeDecodeError as exc:
# AI-assisted fix (Claude, Anthropic): see the commit message.
compilation_unit.diagnostics.add(
Diagnostic(
"error",
f"Could not decode file as utf-8: {exc.reason}.",
)
)

elif step < len(self.steps):
if not compilation_unit.ast:
Expand Down
51 changes: 46 additions & 5 deletions packages/mecha/src/mecha/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,49 @@
import logging
import os
import zipfile
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Optional, Tuple

import click
from beet import LATEST_MINECRAFT_VERSION, Context, DataPack, Function, run_beet
from beet.core.utils import resolve_within, split_version
from beet.toolchain.cli import BeetCommand, LogHandler, error_handler, message_fence

from mecha import __version__

from .api import Mecha
from .diagnostic import Diagnostic


@contextmanager
def report_decode_errors(mc: Mecha, filename: str) -> Iterator[None]:
"""Report a file that is not valid utf-8 as a diagnostic instead of crashing."""
# AI-assisted fix (Claude, Anthropic): see the commit message.
try:
yield
except UnicodeDecodeError as exc:
mc.diagnostics.add(
Diagnostic(
"error",
f"Could not decode a file as utf-8: {exc.reason}.",
filename=filename,
)
)


def check_minecraft_version(
ctx: click.Context, param: click.Parameter, value: str
) -> str:
"""Reject values that cannot be parsed as a version number."""
# AI-assisted fix (Claude, Anthropic): see the commit message.
try:
split_version(value)
except ValueError:
raise click.BadParameter(
f"{value!r} is not a valid Minecraft version."
) from None
return value


def validate(ctx: Context):
Expand All @@ -27,14 +61,19 @@ def validate(ctx: Context):
path = ctx.directory / path

if zipfile.is_zipfile(path) or (path / "data").is_dir():
mc.compile(DataPack(path=path), report=mc.diagnostics)
with report_decode_errors(mc, str(resolve_within(path, ctx.directory))):
mc.compile(DataPack(path=path), report=mc.diagnostics)

elif path.is_dir():
for filename in sorted(path.glob("**/*.mcfunction")):
mc.compile(Function(source_path=filename), report=mc.diagnostics)
with report_decode_errors(
mc, str(resolve_within(filename, ctx.directory))
):
mc.compile(Function(source_path=filename), report=mc.diagnostics)

elif path.is_file():
mc.compile(Function(source_path=path), report=mc.diagnostics)
with report_decode_errors(mc, str(resolve_within(path, ctx.directory))):
mc.compile(Function(source_path=path), report=mc.diagnostics)


@click.command(
Expand All @@ -47,6 +86,7 @@ def validate(ctx: Context):
"--minecraft",
metavar="VERSION",
default=LATEST_MINECRAFT_VERSION,
callback=check_minecraft_version,
help="Minecraft version.",
)
@click.option(
Expand All @@ -70,7 +110,7 @@ def validate(ctx: Context):
"-j",
"--json",
metavar="FILENAME",
help="Output json.",
help="Write statistics to a json file (collects statistics).",
)
@click.version_option(
__version__,
Expand All @@ -97,7 +137,8 @@ def mecha(

config = {
"minecraft": minecraft,
"require": stats * ["mecha.contrib.statistics"],
# AI-assisted fix (Claude, Anthropic): see the commit message.
"require": ["mecha.contrib.statistics"] if stats or json else [],
"pipeline": ["mecha.cli.validate"],
"meta": {
"source": source,
Expand Down
15 changes: 13 additions & 2 deletions packages/mecha/src/mecha/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ def underline_code(
gutter = [f"{i + view_start_line} |" for i in range(len(view))]

for line in reversed(range(lineno, end_lineno + 1)):
# AI-assisted fix (Claude, Anthropic): see the commit message.
# A span ending at column 1 of a later line (e.g. a newline token)
# does not cover any character of that line.
if line > lineno and line == end_lineno and end_colno == 1:
continue

index = line - view_start_line
code = view[index] if index < len(view) else ""
start = colno if line == lineno else 1
Expand All @@ -228,8 +234,13 @@ def underline_code(
if start >= stop:
stop = start + 1

start = max(start, len(code) - len(code.lstrip()) + 1)
stop = min(stop, len(code.rstrip()) + 1)
clamped_start = max(start, len(code) - len(code.lstrip()) + 1)
clamped_stop = min(stop, len(code.rstrip()) + 1)

if clamped_start < clamped_stop:
start, stop = clamped_start, clamped_stop
elif line != lineno or start <= len(code.rstrip()):
continue

if start < stop:
underline = NOTABS_REGEX.sub(" ", code[: start - 1]) + "^" * (stop - start)
Expand Down
111 changes: 111 additions & 0 deletions packages/mecha/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# AI-assisted (Claude, Anthropic): see the commit message.
import json
import subprocess
import sys
from pathlib import Path


def run_mecha(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "mecha", *args],
cwd=cwd,
capture_output=True,
text=True,
)


def test_json_output_without_stats_flag(tmp_path: Path):
(tmp_path / "foo.mcfunction").write_text("say hello\n")

result = run_mecha(tmp_path, "-j", "stats.json", "foo.mcfunction")

assert result.returncode == 0, result.stdout + result.stderr
data = json.loads((tmp_path / "stats.json").read_text())
assert data["function_count"] == 1


def test_json_output_with_stats_flag(tmp_path: Path):
(tmp_path / "foo.mcfunction").write_text("say hello\n")

result = run_mecha(tmp_path, "-s", "-j", "stats.json", "foo.mcfunction")

assert result.returncode == 0, result.stdout + result.stderr
assert json.loads((tmp_path / "stats.json").read_text())["function_count"] == 1


def test_no_json_file_without_json_flag(tmp_path: Path):
(tmp_path / "foo.mcfunction").write_text("say hello\n")

result = run_mecha(tmp_path, "foo.mcfunction")

assert result.returncode == 0, result.stdout + result.stderr
assert list(tmp_path.glob("*.json")) == []


def test_undecodable_file_is_reported_without_traceback(tmp_path: Path):
(tmp_path / "foo.mcfunction").write_bytes(b"say \xff\xfe\n")

result = run_mecha(tmp_path, "foo.mcfunction")
output = result.stdout + result.stderr

assert result.returncode == 1, output
assert "as utf-8" in output
assert "foo.mcfunction" in output
assert "Traceback" not in output


def test_undecodable_file_does_not_stop_directory_validation(tmp_path: Path):
functions = tmp_path / "functions"
functions.mkdir()
(functions / "a.mcfunction").write_bytes(b"say \xff\xfe\n")
(functions / "b.mcfunction").write_text("say ok\nfoo bar\n")
(functions / "c.mcfunction").write_text("say fine\n")

result = run_mecha(tmp_path, "functions")
output = result.stdout + result.stderr

assert result.returncode == 1, output
assert "Reported 2 errors" in output
assert "Traceback" not in output


def test_invalid_minecraft_version_is_a_usage_error(tmp_path: Path):
(tmp_path / "foo.mcfunction").write_text("say hello\n")

result = run_mecha(tmp_path, "-m", "abc", "foo.mcfunction")
output = result.stdout + result.stderr

assert result.returncode == 2, output
assert "is not a valid Minecraft version" in output
assert "Traceback" not in output


def test_valid_minecraft_version(tmp_path: Path):
(tmp_path / "foo.mcfunction").write_text("say hello\n")

result = run_mecha(tmp_path, "-m", "1.20", "foo.mcfunction")

assert result.returncode == 0, result.stdout + result.stderr


def test_undecodable_function_in_data_pack_is_reported_per_file(tmp_path: Path):
pack = tmp_path / "pack"
(pack / "data" / "demo" / "function").mkdir(parents=True)
(pack / "pack.mcmeta").write_text(
'{"pack": {"pack_format": 48, "description": "test"}}'
)
(pack / "data" / "demo" / "function" / "broken.mcfunction").write_bytes(
b"say \xff\xfe\n"
)
(pack / "data" / "demo" / "function" / "bad.mcfunction").write_text(
"say ok\nfoo bar\n"
)
(pack / "data" / "demo" / "function" / "fine.mcfunction").write_text("say fine\n")

result = run_mecha(tmp_path, "pack")
output = result.stdout + result.stderr

assert result.returncode == 1, output
assert "pack/data/demo/function/broken.mcfunction" in output
assert "Reported 2 errors" in output
assert "Traceback" not in output
24 changes: 24 additions & 0 deletions packages/mecha/tests/test_decode_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# AI-assisted (Claude, Anthropic): see the commit message.
from pathlib import Path

import pytest
from beet import DataPack, Function

from mecha import DiagnosticError, Mecha


def test_compile_reports_undecodable_function(mc: Mecha, tmp_path: Path):
path = tmp_path / "broken.mcfunction"
path.write_bytes(b"say \xff\xfe\n")

pack = DataPack()
pack["demo:broken"] = Function(source_path=path)
pack["demo:fine"] = Function("say fine\n")

with pytest.raises(DiagnosticError) as exc_info:
mc.compile(pack)

errors = list(exc_info.value.diagnostics.get_all_errors())
assert len(errors) == 1
assert "utf-8" in errors[0].message
assert errors[0].hint == "demo:broken"
67 changes: 67 additions & 0 deletions packages/mecha/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# AI-assisted (Claude, Anthropic): see the commit message.
from tokenstream import SourceLocation

from mecha.utils import underline_code


def loc(pos: int, lineno: int, colno: int) -> SourceLocation:
return SourceLocation(pos, lineno, colno)


def lines(text: str) -> list[str]:
return [line.rstrip() for line in text.splitlines()]


def test_underline_token():
source = "say ok\nfoo bar\nsay z\n"
result = underline_code(source, loc(7, 2, 1), loc(10, 2, 4))
assert lines(result) == [
" 1 | say ok",
" 2 | foo bar",
" : ^^^",
" 3 | say z",
]


def test_underline_newline_token_points_at_end_of_line():
# The newline token ends at column 1 of the next line. The caret must be
# drawn on the line that is missing something, not under the next line.
source = "say a\nexecute as @a run\nsay b\n"
result = underline_code(source, loc(23, 2, 18), loc(24, 3, 1))
assert lines(result) == [
" 1 | say a",
" 2 | execute as @a run",
" : ^",
" 3 | say b",
]


def test_underline_newline_token_at_end_of_file():
source = "execute as @a run\n"
result = underline_code(source, loc(17, 1, 18), loc(18, 2, 1))
assert lines(result) == [
" 1 | execute as @a run",
" : ^",
]


def test_underline_eof_token_without_trailing_newline():
source = "say a\nexecute as @a run"
result = underline_code(source, loc(23, 2, 18), loc(23, 2, 18))
assert lines(result) == [
" 1 | say a",
" 2 | execute as @a run",
" : ^",
]


def test_underline_multiline_span_still_covers_both_lines():
source = "say a\nsay bc\nsay d\n"
result = underline_code(source, loc(4, 1, 5), loc(10, 2, 4))
assert lines(result) == [
" 1 | say a",
" : ^",
" 2 | say bc",
" : ^^^",
" 3 | say d",
]