mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ac43ff5d | ||
|
|
57e676ad49 | ||
|
|
8ba9a1d9ea | ||
|
|
1b18062368 | ||
|
|
93c34ad534 | ||
|
|
d2de4c18de | ||
|
|
037298b5a7 |
No files matched your search
@@ -1,9 +1,10 @@
|
||||
# Native mods on the newest Nightly, as players' launchers build them (scripts/native_compat.py):
|
||||
# - on a pull request, each new native-mod version, applied together with the most downloaded native mods
|
||||
# and compiled (`cargo check`); its support modules' names against every registry mod's;
|
||||
# - every day after the Nightly, every native mod's newest version, applied: the ones horizon-mixins
|
||||
# refuses go to the `native-health` release, which publish adds to the index (`broken_on`) so the
|
||||
# launcher warns before an install.
|
||||
# - every day after the Nightly, every native mod's newest version, applied, then the rest compiled
|
||||
# (`cargo check`): the ones horizon-mixins refuses alone and the ones whose own code doesn't compile go
|
||||
# to the `native-health` release, which publish adds to the index (`broken_on`) so the launcher warns
|
||||
# before an install.
|
||||
# The check compiles the submission, whose crates may have build scripts: it runs with a token that can
|
||||
# only read, and main's script, never the pull request's.
|
||||
name: native-compat
|
||||
@@ -18,6 +19,9 @@ on:
|
||||
horizon:
|
||||
description: "Horizon commit, or `latest` (the newest Nightly)"
|
||||
default: latest
|
||||
upload:
|
||||
description: "Publish the result (native-health release); `false` for a trial run"
|
||||
default: "true"
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
@@ -69,7 +73,20 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||
- run: rustup toolchain install $RUST --profile minimal && rustup default $RUST
|
||||
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config
|
||||
# Yesterday's dependencies, checked, and horizon-mixins: only Horizon's own crates and the mods compile again.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
native/target/compat/cargo
|
||||
native/target/horizon-mixins
|
||||
key: native-health-${{ env.RUST }}-${{ github.run_id }}
|
||||
restore-keys: native-health-${{ env.RUST }}-
|
||||
- name: Every native mod's newest version on the newest Nightly
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -78,6 +95,7 @@ jobs:
|
||||
set -o pipefail
|
||||
python3 scripts/native_compat.py health --horizon "$HORIZON" --out native-health.json | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: Keep it for publish
|
||||
if: inputs.upload != 'false'
|
||||
run: |
|
||||
gh release view native-health >/dev/null 2>&1 \
|
||||
|| gh release create native-health --prerelease --title "Native mods' health" \
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,43 @@
|
||||
id = "worldmap"
|
||||
kind = "native-mod"
|
||||
name = "World Map"
|
||||
summary = "A full screen, self-writing world map: cave layers, waypoints, dimensions, teleport and PNG export."
|
||||
description = """
|
||||
A full screen map that writes itself as you explore.
|
||||
|
||||
- **Self-writing map.** The terrain you have seen stays on the map after you leave, kept per world, dimension and cave layer, in the vanilla map colours.
|
||||
- **Smooth.** Drag to move, scroll to zoom towards the pointer; far zoom blurs instead of flickering.
|
||||
- **Cave layers.** Under a roof, in a cave or in the Nether the map shows the floor round you; layered (a map for each 16 blocks of height) or full, with a Top Y slider from -64 to 319.
|
||||
- **Dimensions.** One button shows the Overworld, the Nether or the End from any of them, with the other world's coordinates of the place under the pointer.
|
||||
- **Waypoints.** Right-click for a menu: add, rename, 16 colours, three shapes, teleport (/tp), delete. Waypoints show in the world with their name and distance, a waypoint is made where you die, and a list with search (L) finds them.
|
||||
- **Also:** the biome under the pointer, other players on the map, go to coordinates (J), a chunk grid, your trail, and an export of the whole map as a PNG.
|
||||
- **Keys.** A World Map section in Options > Controls > Key Binds (with Reset), ten keys, all rebindable. Open the map with M.
|
||||
- **Russian** interface when the game is in Russian.
|
||||
|
||||
**What it does and doesn't do:** it reads the chunks the game has loaded around you and the game's own state, draws on its own screen, and keeps files in `config/worldmap/` (the explored chunks and a waypoints file for each world). It sends `/tp` in chat when you choose Teleport. It makes no network connection and needs no other mod.
|
||||
|
||||
**Native mod, pinned** to Horizon Nightly `66f3981` (Minecraft 26.3): it adds rows to the game's Key Binds screen and reuses the minimap's colour scan, so it builds on that revision."""
|
||||
authors = ["Px228-Da-Da"]
|
||||
owners = ["Px228-Da-Da"]
|
||||
owner_ids = [160890712]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Px228-Da-Da/horizon-worldmap"
|
||||
icon = "icons/worldmap.png"
|
||||
categories = ["utility", "adventure"]
|
||||
tags = ["map", "worldmap", "waypoints", "navigation", "caves", "minimap"]
|
||||
screenshots = ["screenshots/worldmap/1.png", "screenshots/worldmap/2.png", "screenshots/worldmap/3.png"]
|
||||
|
||||
[[versions]]
|
||||
version = "0.5.0"
|
||||
published = "2026-11-06"
|
||||
game = "26.3"
|
||||
file = "worldmap-0.5.0.hnmod"
|
||||
sha256 = "970b1186fdcd9f7b479e4a838747489a2f286b7a7dcf5cfff76bc912a2c5c7dd"
|
||||
changelog = """
|
||||
First public release.
|
||||
|
||||
- Self-writing full screen map, kept per world, dimension and cave layer
|
||||
- Smooth drag and zoom, cave layers with a Top Y slider, dimension button
|
||||
- Waypoints (colours, shapes, teleport, death waypoint) with a searchable list
|
||||
- Biome, other players, go to coordinates, chunk grid, PNG export
|
||||
- Ten rebindable keys in Key Binds, a settings page, Russian interface"""
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
+123
-16
@@ -13,8 +13,10 @@
|
||||
Popular mods that don't apply on that Nightly are left out, as the launcher leaves them out, and listed.
|
||||
|
||||
`health` applies every native mod's newest version on the newest Nightly and writes the ones horizon-mixins
|
||||
refuses, with its diagnostic: the publish job adds them to the index (`broken_on`), and the launcher warns
|
||||
before an install.
|
||||
refuses with only the mods they need; then `cargo check`s the rest together, rounds leaving out the mods the errors
|
||||
are in (scripts/native_trace.py, as the launcher traces them), and writes those too, each with its first error. An
|
||||
error of the kind two mods cause together (a name defined twice...) is checked again with that mod alone. The publish
|
||||
job adds them all to the index (`broken_on`), and the launcher warns before an install.
|
||||
|
||||
horizon-mixins only reads the bundles; `cargo check` runs a mod crate's build script, if it has one, so
|
||||
`check` runs in a job whose token can only read (native-compat.yml). Horizon's source comes from GitHub's archive.
|
||||
@@ -34,6 +36,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "native" / "tools"))
|
||||
import native # noqa: E402 (native/tools/native.py: source, latest_nightly, get, log)
|
||||
import native_trace # noqa: E402
|
||||
|
||||
INDEX = "https://horizon-rust.github.io/horizon-mods/index.json"
|
||||
REGISTRY = "horizon-rust/horizon-mods"
|
||||
@@ -60,6 +63,10 @@ class Bundle:
|
||||
self.modules = {(m["file"], m["name"]) for m in manifest.get("module", [])}
|
||||
self.needs = [d for d, v in manifest.get("dependencies", {}).items()
|
||||
if not (isinstance(v, dict) and v.get("optional"))]
|
||||
# The launcher rewrites these before horizon-mixins and stages API packages for them (native_mods/inject.rs,
|
||||
# api.rs), which this script doesn't: they are applied here, not compiled.
|
||||
self.api = "api" in manifest or any(isinstance(i, dict) and "trait_method" in i
|
||||
for i in manifest.get("injection", []))
|
||||
|
||||
def unpack(self, into, revision):
|
||||
out = into / self.id
|
||||
@@ -172,15 +179,85 @@ def apply(tool, tree, bundles, revision, name):
|
||||
return None, refused
|
||||
|
||||
|
||||
def compiles(stage):
|
||||
"""`cargo check` of the game in `stage`, as the launcher compiles it."""
|
||||
# Errors two mods' code can cause together, while each alone compiles.
|
||||
PAIR_CODES = {"E0119", "E0201", "E0252", "E0254", "E0255", "E0260", "E0428", "E0592"}
|
||||
COMPILE_ROUNDS = 6
|
||||
|
||||
|
||||
def check_errors(stage):
|
||||
"""`cargo check` of the game in `stage` as the launcher compiles it (with Horizon's `mods` feature when it has
|
||||
one): None when it compiles, else its errors (native_trace.errors). The workspace is synced into one folder
|
||||
first, so cargo's incremental state survives between rounds."""
|
||||
work = WORK / "check"
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["rsync", "-a", "--checksum", "--delete", f"{stage}/", f"{work}/"], check=True)
|
||||
command = ["cargo", "check", "--locked", "--message-format=json", "-p", "horizon-client", "--bin", "horizon-client"]
|
||||
client = tomllib.loads((work / "crates/horizon-client/Cargo.toml").read_text())
|
||||
if "mods" in client.get("features", {}):
|
||||
command += ["--features", "horizon-client/mods"]
|
||||
env = dict(os.environ, CARGO_TARGET_DIR=str(WORK / "cargo"))
|
||||
run = subprocess.run(["cargo", "check", "--locked", "-p", "horizon-client", "--bin", "horizon-client"],
|
||||
cwd=stage, env=env, capture_output=True, text=True)
|
||||
if run.returncode != 0:
|
||||
errors = [line for line in run.stderr.splitlines() if line.startswith("error")]
|
||||
log("\n".join(errors[:10]) or run.stderr[-3000:])
|
||||
return run.returncode == 0
|
||||
run = subprocess.run(command, cwd=work, env=env, capture_output=True, text=True)
|
||||
if run.returncode == 0:
|
||||
return None
|
||||
found = native_trace.errors(run.stdout)
|
||||
for e in found[:10]:
|
||||
log(e["rendered"].rstrip())
|
||||
return found or [{"code": "E_CARGO", "message": run.stderr[-2000:], "rendered": run.stderr[-2000:], "spans": []}]
|
||||
|
||||
|
||||
def compiles(stage):
|
||||
return stage is not None and check_errors(stage) is None
|
||||
|
||||
|
||||
def traced(errors, tree, mods):
|
||||
"""{id: its first error} for the errors in `mods`' own code (native_trace)."""
|
||||
tracer = native_trace.Tracer(WORK / "check", tree, {(b.id, b.version) for b in mods})
|
||||
found = {}
|
||||
for e in errors:
|
||||
for span in e["spans"]:
|
||||
owner = tracer.owner(span)
|
||||
if owner and owner not in found:
|
||||
where = f" ({span['file_name']}:{span.get('line_start')})"
|
||||
found[owner] = {"code": e["code"], "message": e["message"] + where}
|
||||
return found
|
||||
|
||||
|
||||
def broken_compiling(tool, tree, mods, revision, needed):
|
||||
"""The mods of `mods` (all applying) whose own code doesn't compile on `revision`: {id: error}."""
|
||||
by_id = {b.id: b for b in mods}
|
||||
left, broken = list(mods), {}
|
||||
for _ in range(COMPILE_ROUNDS):
|
||||
stage, _ = apply(tool, tree, left, revision, "health-compile")
|
||||
if stage is None:
|
||||
break
|
||||
errors = check_errors(stage)
|
||||
if errors is None:
|
||||
log(f" {len(left)} native mods compile together")
|
||||
return broken
|
||||
found = traced(errors, tree, left)
|
||||
if not found:
|
||||
log(" the errors are in no mod's code: stopping the compile check here")
|
||||
break
|
||||
for i, error in sorted(found.items()):
|
||||
if error["code"] in PAIR_CODES:
|
||||
alone = [by_id[n] for n in sorted(needed(by_id[i], set())) if n in by_id] + [by_id[i]]
|
||||
stage, _ = apply(tool, tree, alone, revision, f"health-alone-{i}")
|
||||
if stage is None or i not in traced(check_errors(stage) or [], tree, alone):
|
||||
log(f" {i}: {error['code']} only next to other mods: not broken")
|
||||
continue
|
||||
log(f" {i} {by_id[i].version}: {error['code']}: {error['message']}")
|
||||
broken[i] = error
|
||||
gone = set(found)
|
||||
while True:
|
||||
more = {b.id for b in left if b.id not in gone and any(n in gone for n in b.needs)}
|
||||
if not more:
|
||||
break
|
||||
for i in more:
|
||||
if any(n in broken for n in by_id[i].needs):
|
||||
broken[i] = {"code": "E_DEPENDENCY", "message": f"needs {', '.join(by_id[i].needs)}"}
|
||||
gone |= more
|
||||
left = [b for b in left if b.id not in gone]
|
||||
return broken
|
||||
|
||||
|
||||
def new_entries(base, head):
|
||||
@@ -265,17 +342,46 @@ def health(args):
|
||||
revision = native.latest_nightly() if args.horizon == "latest" else args.horizon
|
||||
tree = native.source(revision)
|
||||
tool = mixins_tool([])
|
||||
_, refused = apply(tool, tree, registry, revision, "health")
|
||||
versions = {b.id: b.version for b in registry}
|
||||
_, together = apply(tool, tree, registry, revision, "health")
|
||||
# A mod refused only next to another (both overwrite one function, both name one module) builds for a
|
||||
# player without that other one: broken on this Horizon is what horizon-mixins refuses with only the mods
|
||||
# it needs.
|
||||
by_id = {b.id: b for b in registry}
|
||||
def needed(bundle, seen):
|
||||
for n in bundle.needs:
|
||||
if n in by_id and n not in seen:
|
||||
seen.add(n)
|
||||
needed(by_id[n], seen)
|
||||
return seen
|
||||
refused = {}
|
||||
for i in sorted(together):
|
||||
mods = [by_id[n] for n in sorted(needed(by_id[i], set()))] + [by_id[i]]
|
||||
_, alone = apply(tool, tree, mods, revision, f"health-{i}")
|
||||
if i in alone:
|
||||
refused[i] = alone[i]
|
||||
else:
|
||||
log(f" {i} applies without the others: not broken")
|
||||
# The rest, compiled; without the mods the launcher prepares differently (Bundle.api) and those needing them.
|
||||
skipped = {b.id for b in registry if b.api}
|
||||
while more := {b.id for b in registry if b.id not in skipped and any(n in skipped for n in b.needs)}:
|
||||
skipped |= more
|
||||
if skipped:
|
||||
log(f" not compiled here (an API the launcher prepares): {', '.join(sorted(skipped))}")
|
||||
compiling = [b for b in registry if b.id not in refused and b.id not in skipped]
|
||||
failed = broken_compiling(tool, tree, compiling, revision, needed) if args.compile else {}
|
||||
broken = {i: dict(d, stage="apply") for i, d in refused.items()}
|
||||
broken |= {i: dict(d, stage="compile") for i, d in failed.items()}
|
||||
report = {
|
||||
"horizon": revision,
|
||||
"mixins": tool.parent.parent.name,
|
||||
"broken": {i: {"version": versions[i], "code": d.get("code"), "message": d.get("message")}
|
||||
for i, d in sorted(refused.items())},
|
||||
"broken": {i: {"version": by_id[i].version, "code": d.get("code"), "message": d.get("message"),
|
||||
"stage": d["stage"]}
|
||||
for i, d in sorted(broken.items())},
|
||||
}
|
||||
Path(args.out).write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(f"{len(refused)} of {len(registry)} native mods don't apply on Horizon {revision[:7]}: "
|
||||
+ ", ".join(sorted(refused)))
|
||||
print(f"{len(broken)} of {len(registry)} native mods don't build on Horizon {revision[:7]}: "
|
||||
f"{len(refused)} don't apply ({', '.join(sorted(refused))}), {len(failed)} don't compile "
|
||||
f"({', '.join(sorted(failed))}).")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -292,6 +398,7 @@ def main():
|
||||
n.add_argument("head")
|
||||
h = sub.add_parser("health")
|
||||
h.add_argument("--out", required=True)
|
||||
h.add_argument("--no-compile", dest="compile", action="store_false")
|
||||
for p in (c, h):
|
||||
p.add_argument("--horizon", default="latest", help="a Horizon commit, or `latest` (the newest Nightly)")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Which native mod a compiler error is in, as the launcher traces it (launcher/src/native_mods/diagnostics.rs).
|
||||
|
||||
An error is a mod's when its primary span lies inside what that mod brought into the staged game: one of its
|
||||
helpers (the function of the helper's name, absent from the pristine source) or one of its support modules (the
|
||||
inline module of its name, absent from the pristine source). An error that more than one mod's code holds, or none,
|
||||
names no mod.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PLAN = ".horizon-mixins-stage.json"
|
||||
|
||||
|
||||
def masked(src):
|
||||
"""`src` (bytes) with comments, strings and char literals blanked, so braces and keywords are code's."""
|
||||
out = bytearray(src)
|
||||
i, n = 0, len(src)
|
||||
|
||||
def blank(a, b):
|
||||
for k in range(a, min(b, n)):
|
||||
if out[k] != ord("\n"):
|
||||
out[k] = ord(" ")
|
||||
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if src.startswith(b"//", i):
|
||||
end = src.find(b"\n", i)
|
||||
end = n if end < 0 else end
|
||||
blank(i, end)
|
||||
i = end
|
||||
elif src.startswith(b"/*", i):
|
||||
depth, k = 1, i + 2
|
||||
while k < n and depth:
|
||||
if src.startswith(b"/*", k):
|
||||
depth, k = depth + 1, k + 2
|
||||
elif src.startswith(b"*/", k):
|
||||
depth, k = depth - 1, k + 2
|
||||
else:
|
||||
k += 1
|
||||
blank(i, k)
|
||||
i = k
|
||||
elif c in b"rb" and (i == 0 or not (chr(src[i - 1]).isalnum() or src[i - 1] == ord("_"))) and \
|
||||
re.match(rb"(?:br|r)(#*)\"", src[i:i + 260]):
|
||||
hashes = re.match(rb"(?:br|r)(#*)\"", src[i:i + 260])
|
||||
close = b'"' + hashes.group(1)
|
||||
end = src.find(close, i + hashes.end())
|
||||
end = n if end < 0 else end + len(close)
|
||||
blank(i, end)
|
||||
i = end
|
||||
elif c == ord('"'):
|
||||
k = i + 1
|
||||
while k < n and src[k] != ord('"'):
|
||||
k += 2 if src[k] == ord("\\") else 1
|
||||
blank(i, k + 1)
|
||||
i = k + 1
|
||||
elif c == ord("'"):
|
||||
# A char literal ('x', '\n', '\u{1F600}'), else a lifetime ('a).
|
||||
if i + 1 < n and src[i + 1] == ord("\\"):
|
||||
end = src.find(b"'", i + 2)
|
||||
end = n if end < 0 else end + 1
|
||||
blank(i, end)
|
||||
i = end
|
||||
elif i + 2 < n and src[i + 2] == ord("'"):
|
||||
blank(i, i + 3)
|
||||
i += 3
|
||||
else:
|
||||
m = re.match(rb"'([^\x00-\x7f]+)'", src[i:i + 8])
|
||||
if m:
|
||||
blank(i, i + m.end())
|
||||
i += m.end()
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def item_ranges(code, keyword, name):
|
||||
"""Byte ranges of the `fn NAME` or `mod NAME { … }` items in masked source `code`, through their closing brace."""
|
||||
pattern = rb"\b%s\s+%s\b" % (keyword.encode(), re.escape(name.encode()))
|
||||
ranges = []
|
||||
for m in re.finditer(pattern, code):
|
||||
# The body's `{`, unless a `;` ends the item first; either outside the signature's () and [] (`[f64; 3]`).
|
||||
open_at, nested, k = -1, 0, m.end()
|
||||
while k < len(code):
|
||||
c = code[k]
|
||||
if c in b"([":
|
||||
nested += 1
|
||||
elif c in b")]":
|
||||
nested -= 1
|
||||
elif nested <= 0 and c in b"{;":
|
||||
open_at = k if c == ord("{") else -1
|
||||
break
|
||||
k += 1
|
||||
if open_at < 0:
|
||||
continue # a declaration without a body
|
||||
if keyword == "mod" and code[m.end():open_at].strip():
|
||||
continue
|
||||
depth, k = 0, open_at
|
||||
while k < len(code):
|
||||
if code[k] == ord("{"):
|
||||
depth += 1
|
||||
elif code[k] == ord("}"):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
ranges.append(_with_call(code, name, m.start(), k + 1) if keyword == "fn" else (m.start(), k + 1))
|
||||
break
|
||||
k += 1
|
||||
return ranges
|
||||
|
||||
|
||||
def _with_call(code, name, start, end):
|
||||
"""The block horizon-mixins wraps a helper in, `{ fn NAME(…) { … } NAME(…) }`, when that is all the block holds:
|
||||
an error in the generated call (an argument the helper's signature no longer takes) is the helper's too."""
|
||||
before = re.search(rb"\{\s*(?:#\[[^\]]*\]\s*)*\Z", code[max(0, start - 400):start])
|
||||
after = re.match(rb"\s*%s\s*\([^{}]*\)\s*;?\s*\}" % re.escape(name.encode()), code[end:])
|
||||
if before and after:
|
||||
return start - (len(before.group(0))), end + after.end()
|
||||
return start, end
|
||||
|
||||
|
||||
def helper_name(source):
|
||||
m = re.search(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)", source or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
class Tracer:
|
||||
"""Traces errors in the staged workspace `stage` (horizon-mixins' output, holding its plan) whose pristine source
|
||||
is `pristine`, to the mods in `mods` ({(id, version)})."""
|
||||
|
||||
def __init__(self, stage, pristine, mods):
|
||||
self.stage, self.pristine = Path(stage).resolve(), Path(pristine)
|
||||
plan = json.loads((self.stage / PLAN).read_text())
|
||||
self.entries = [] # (id, version, file, keyword, name)
|
||||
for e in plan.get("injections", []):
|
||||
name = helper_name(e.get("helper_source"))
|
||||
if name:
|
||||
self.entries.append((e.get("mod_id"), e.get("mod_version"), e.get("file"), "fn", name))
|
||||
for e in plan.get("modules", []):
|
||||
self.entries.append((e.get("mod_id"), e.get("mod_version"), e.get("file"), "mod", e.get("name")))
|
||||
self.entries = [e for e in self.entries if (e[0], e[1]) in mods and e[2] and e[4]]
|
||||
self.cache, self.code = {}, {}
|
||||
|
||||
def _masked(self, path):
|
||||
if path not in self.code:
|
||||
self.code[path] = masked(path.read_bytes()) if path.is_file() else b""
|
||||
return self.code[path]
|
||||
|
||||
def _ranges(self, rel, keyword, name):
|
||||
key = (rel, keyword, name)
|
||||
if key not in self.cache:
|
||||
found = []
|
||||
if (self.stage / rel).is_file() and not item_ranges(self._masked(self.pristine / rel), keyword, name):
|
||||
found = item_ranges(self._masked(self.stage / rel), keyword, name)
|
||||
self.cache[key] = found
|
||||
return self.cache[key]
|
||||
|
||||
def owner(self, span):
|
||||
"""The mod (id) whose code holds a cargo JSON span, or None."""
|
||||
try:
|
||||
path = (self.stage / span["file_name"]).resolve()
|
||||
rel = path.relative_to(self.stage).as_posix()
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
start, end = span.get("byte_start", 0), span.get("byte_end", 0)
|
||||
owners = {i for i, _, file, keyword, name in self.entries
|
||||
if file == rel and any(a <= start and end <= b for a, b in self._ranges(rel, keyword, name))}
|
||||
return owners.pop() if len(owners) == 1 else None
|
||||
|
||||
|
||||
def errors(cargo_stdout):
|
||||
"""The errors in `cargo --message-format=json` output: {code, message, rendered, spans (primary)}."""
|
||||
found = []
|
||||
for line in cargo_stdout.splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if value.get("reason") != "compiler-message":
|
||||
continue
|
||||
message = value.get("message") or {}
|
||||
if message.get("level") != "error":
|
||||
continue
|
||||
found.append({
|
||||
"code": (message.get("code") or {}).get("code") or "E_COMPILE",
|
||||
"message": message.get("message", ""),
|
||||
"rendered": message.get("rendered") or "",
|
||||
"spans": [s for s in message.get("spans", []) if s.get("is_primary")],
|
||||
})
|
||||
return found
|
||||
@@ -0,0 +1,110 @@
|
||||
"""python3 -m unittest discover -s scripts -p 'test_*.py'"""
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from native_trace import Tracer, errors, item_ranges, masked
|
||||
|
||||
PRISTINE = """struct MeshTables { base: u8, layers: u8 }
|
||||
pub fn resolve(x: u8) -> u8 {
|
||||
x
|
||||
}
|
||||
"""
|
||||
# One mod's helper inside a target (formatted with a trailing comma, an array type's `;` in its signature), another's
|
||||
# support module; braces in strings, chars and comments must not end them early.
|
||||
STAGED = """struct MeshTables { base: u8, layers: u8 }
|
||||
pub fn resolve(x: u8) -> u8 {
|
||||
if let Some(v) = {
|
||||
fn fast_mesh_resolve(x: u8, _at: [f64; 3],) -> Option<u8> {
|
||||
let _ = "}"; let _ = '}'; // }
|
||||
let _ = r#"}"#;
|
||||
Some(MeshTables { base: x }.base)
|
||||
}
|
||||
fast_mesh_resolve(x, [0.0; 3])
|
||||
} {
|
||||
return v;
|
||||
}
|
||||
x
|
||||
}
|
||||
mod carry_tables {
|
||||
pub fn lift<'a>(t: &'a super::MeshTables) -> u8 { t.missing }
|
||||
}
|
||||
fn untouched() -> u8 { MeshTables { base: 1 }.base }
|
||||
"""
|
||||
|
||||
|
||||
def span(text, needle, file="src/main.rs"):
|
||||
start = STAGED.encode().index(needle.encode())
|
||||
return {"file_name": file, "byte_start": start, "byte_end": start + len(needle), "is_primary": True}
|
||||
|
||||
|
||||
class Trace(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
(self.dir / "stage/src").mkdir(parents=True)
|
||||
(self.dir / "pristine/src").mkdir(parents=True)
|
||||
(self.dir / "stage/src/main.rs").write_text(STAGED)
|
||||
(self.dir / "pristine/src/main.rs").write_text(PRISTINE)
|
||||
self.plan([
|
||||
{"mod_id": "fast_mesh", "mod_version": "0.1.0", "file": "src/main.rs",
|
||||
"helper_source": "fn fast_mesh_resolve(x: u8) -> Option<u8> { Some(MeshTables { base: x }.base) }"},
|
||||
], [{"mod_id": "carryblock", "mod_version": "0.1.2", "file": "src/main.rs", "name": "carry_tables"}])
|
||||
self.mods = {("fast_mesh", "0.1.0"), ("carryblock", "0.1.2")}
|
||||
|
||||
def plan(self, injections, modules):
|
||||
(self.dir / "stage/.horizon-mixins-stage.json").write_text(
|
||||
json.dumps({"schema": 6, "injections": injections, "modules": modules}))
|
||||
|
||||
def tracer(self, mods=None):
|
||||
return Tracer(self.dir / "stage", self.dir / "pristine", mods or self.mods)
|
||||
|
||||
def test_an_error_in_a_helper_names_its_mod_though_formatting_changed_it(self):
|
||||
self.assertEqual(self.tracer().owner(span(STAGED, "MeshTables { base: x }")), "fast_mesh")
|
||||
|
||||
def test_an_error_in_a_support_module_names_its_mod(self):
|
||||
self.assertEqual(self.tracer().owner(span(STAGED, "t.missing")), "carryblock")
|
||||
|
||||
def test_an_error_in_the_generated_call_to_a_helper_names_its_mod(self):
|
||||
self.assertEqual(self.tracer().owner(span(STAGED, "[0.0; 3]")), "fast_mesh")
|
||||
|
||||
def test_an_error_in_horizons_own_code_names_no_mod(self):
|
||||
self.assertIsNone(self.tracer().owner(span(STAGED, "MeshTables { base: 1 }")))
|
||||
self.assertIsNone(self.tracer().owner(span(STAGED, "return v")))
|
||||
|
||||
def test_a_path_through_another_crate_folder_resolves(self):
|
||||
(self.dir / "stage/lib").mkdir()
|
||||
s = span(STAGED, "t.missing", file="lib/../src/main.rs")
|
||||
self.assertEqual(self.tracer().owner(s), "carryblock")
|
||||
|
||||
def test_a_function_the_pristine_source_has_is_not_a_mods(self):
|
||||
(self.dir / "pristine/src/main.rs").write_text(PRISTINE + "fn fast_mesh_resolve() {}\n")
|
||||
self.assertIsNone(self.tracer().owner(span(STAGED, "MeshTables { base: x }")))
|
||||
|
||||
def test_a_mod_not_built_or_two_owners_name_no_mod(self):
|
||||
self.assertIsNone(self.tracer({("carryblock", "0.1.2")}).owner(span(STAGED, "MeshTables { base: x }")))
|
||||
self.plan([{"mod_id": m, "mod_version": "1", "file": "src/main.rs",
|
||||
"helper_source": "fn fast_mesh_resolve() {}"} for m in ("a", "b")], [])
|
||||
self.assertIsNone(self.tracer({("a", "1"), ("b", "1")}).owner(span(STAGED, "MeshTables { base: x }")))
|
||||
|
||||
def test_braces_in_literals_and_comments_are_not_code(self):
|
||||
code = masked(STAGED.encode())
|
||||
(start, end), = item_ranges(code, "fn", "fast_mesh_resolve")
|
||||
self.assertTrue(STAGED.encode()[start:end].endswith(b"}.base)\n }\n fast_mesh_resolve(x, [0.0; 3])\n }"))
|
||||
self.assertEqual(len(code), len(STAGED.encode()))
|
||||
|
||||
def test_cargo_json_errors(self):
|
||||
out = "\n".join([
|
||||
"not json",
|
||||
json.dumps({"reason": "compiler-artifact"}),
|
||||
json.dumps({"reason": "compiler-message", "message": {"level": "warning", "message": "w", "spans": []}}),
|
||||
json.dumps({"reason": "compiler-message", "message": {
|
||||
"level": "error", "message": "missing fields", "code": {"code": "E0063"}, "rendered": "error[E0063]",
|
||||
"spans": [{"file_name": "a.rs", "is_primary": True}, {"file_name": "b.rs", "is_primary": False}]}}),
|
||||
])
|
||||
(only,) = errors(out)
|
||||
self.assertEqual((only["code"], [s["file_name"] for s in only["spans"]]), ("E0063", ["a.rs"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user