Compare commits

...
Author SHA1 Message Date
Ludovic MarieandPx228-Da-Da a3ac43ff5d Add worldmap 0.5.0
Submitted by @Px228-Da-Da through the form.

Submission: jeIDcPjSgm8DjiP8ikvZ8ItoBJa-EzlI3_0nDu_ql5E
Co-authored-by: Px228-Da-Da <[email protected]>
2026-11-06 19:17:20 +01:00
Ludovic MarieandPx228-Da-Da 57e676ad49 Add worldmap 0.5.0
Submitted by @Px228-Da-Da through the form.

Submission: jeIDcPjSgm8DjiP8ikvZ8ItoBJa-EzlI3_0nDu_ql5E
Co-authored-by: Px228-Da-Da <[email protected]>
2026-11-06 19:17:18 +01:00
Ludovic MarieandPx228-Da-Da 8ba9a1d9ea Add worldmap 0.5.0
Submitted by @Px228-Da-Da through the form.

Submission: jeIDcPjSgm8DjiP8ikvZ8ItoBJa-EzlI3_0nDu_ql5E
Co-authored-by: Px228-Da-Da <[email protected]>
2026-11-06 19:17:16 +01:00
Ludovic MarieandPx228-Da-Da 1b18062368 Add worldmap 0.5.0
Submitted by @Px228-Da-Da through the form.

Submission: jeIDcPjSgm8DjiP8ikvZ8ItoBJa-EzlI3_0nDu_ql5E
Co-authored-by: Px228-Da-Da <[email protected]>
2026-11-06 19:17:14 +01:00
Ludovic MarieandPx228-Da-Da 93c34ad534 Add worldmap 0.5.0
Submitted by @Px228-Da-Da through the form.

Submission: jeIDcPjSgm8DjiP8ikvZ8ItoBJa-EzlI3_0nDu_ql5E
Co-authored-by: Px228-Da-Da <[email protected]>
2026-11-06 19:17:12 +01:00
d2de4c18de native health: cargo check the mods that apply, and mark those whose own code doesn't compile (#313)
* native health: also cargo check, and mark the mods whose own code doesn't compile

The daily check only applied the mods, so versions that apply but don't
compile on the newest Nightly (eatinganimation's ItemRenderState, hitbox_plus's
module path, carryblock's removed Dim::min_y...) weren't marked broken_on,
while every player's build leaves them out.

After the apply pass, the mods that apply are checked together (cargo check,
with Horizon's `mods` feature) in rounds: each round's errors are traced to the
mod whose helper or support module holds them (scripts/native_trace.py, the
launcher's rule), those mods and the ones needing them leave, and the rest
compile again. An error of the kind two mods cause together (a name defined
twice...) is checked again with that mod alone, so a pair is never marked.
Mods that use an API the launcher prepares ([api], trait_method) aren't
compiled here. Dependencies and horizon-mixins come from the Actions cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* native health: trace helpers whose signature holds an array type, mask each file once

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* native health: an error in the call horizon-mixins generates for a helper is the helper's mod's

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-06 00:53:49 +01:00
037298b5a7 native health: mark only the mods that don't apply with just what they need (#309)
The first daily run (42099210086) marked horizon_physics broken for an
E_OVERWRITE_CONFLICT with another mod: a pair conflict, not a mod that
fails on the Nightly. Each mod refused in the full set is now applied
again with only the mods it needs, and is broken on that Horizon only if
horizon-mixins still refuses it.

Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 21:19:25 +01:00
68bd653d9c Check native mods on the newest Nightly, together, before publish (#308)
Players' native builds broke on mods made for an older Horizon (targets moved
by the crate split, a support module refused by a glob) and the whole build
failed. scripts/native_compat.py applies mods as the launcher builds them:

- check (pull requests): each new native-mod version on the newest Nightly
  with the 20 most downloaded native mods, horizon-mixins apply then cargo
  check, and its support modules' (file, name) against every registry mod's.
- health (daily, native-compat.yml): every native mod's newest version on the
  newest Nightly; the ones horizon-mixins refuses go to the native-health
  release, and publish marks them `broken_on` in index.json so the launcher
  warns before an install.

The PR job runs main's script with a read-only token; the submission's build
scripts run under that token only.

Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 20:59:55 +01:00
Ludovic Marieandsoufied c7432500fc Update appleskin 0.1.3 (#292)
Submitted by @soufied through the form.

Submission: xVzH0PsQT1RhD-WbhlYnhTwha3LQOS70M03EJw_JQyQ

Co-authored-by: soufied <[email protected]>
2026-11-05 18:41:17 +01:00
d0d3e68ec4 SMP: the /vanish reminder sits on the action bar, not mid-screen (#298)
rdbt (operator, #mod-chat 11-05): the centred 'Vanished: only operators see
you' line covers the view. It is now an action bar line refreshed once a
second; unvanishing sends an empty action bar instead of clearing titles.
proof.py checks the action bar and that nothing shows mid-screen.

Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 18:38:01 +01:00
16 changed files with 914 additions and 25 deletions

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
# 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, 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
on:
pull_request:
branches: [main]
paths: ["mods/*.toml"]
schedule:
- cron: "40 18 * * *"
workflow_dispatch:
inputs:
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:
group: native-compat-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
# The Rust the launcher builds native mods with (Horizon's launcher/src/rust_toolchain.rs).
RUST: "1.99.1"
jobs:
check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- uses: actions/checkout@v4
with:
path: head
persist-credentials: false
- name: Does it change a native mod?
id: native
run: |
if [ -n "$(python3 base/scripts/native_compat.py new base head)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.native.outputs.changed == 'true'
run: rustup toolchain install $RUST --profile minimal && rustup default $RUST
- if: steps.native.outputs.changed == 'true'
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config
- if: steps.native.outputs.changed == 'true'
name: On the newest Nightly, with the popular native mods
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -o pipefail
python3 base/scripts/native_compat.py check base head | tee -a "$GITHUB_STEP_SUMMARY"
health:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
# Each mod horizon-mixins refuses costs one more apply of the whole set.
timeout-minutes: 300
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
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 }}
HORIZON: ${{ inputs.horizon || 'latest' }}
run: |
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" \
--notes "native-health.json: the native mods that don't apply on the newest Nightly (native-compat.yml). publish adds them to the index as broken_on."
gh release upload native-health native-health.json --clobber
+15
View File
@@ -73,6 +73,21 @@ jobs:
| if $n > 0 then .downloads = $n else . end)' site/index.json > index.counted.json
mv index.counted.json site/index.json
jq -r '.projects[] | "\(.id) \(.downloads // 0)"' site/index.json
- name: Native mods that don't apply on the newest Nightly
run: |
# native-compat.yml's daily health: the version each names gets `broken_on`, the Horizon
# commit horizon-mixins refused it on and its code, so the launcher warns before an install.
if gh release download native-health --pattern native-health.json --dir health 2>/dev/null; then
jq --slurpfile h health/native-health.json '
$h[0] as $health
| .projects |= map(. as $p | .versions |= map(
($health.broken[$p.id] // null) as $b
| if $b != null and $b.version == .version
then .broken_on = [{horizon: $health.horizon, code: $b.code}] else . end))' \
site/index.json > index.health.json
mv index.health.json site/index.json
jq -r '.projects[] | select(.versions[0].broken_on) | "\(.id) \(.versions[0].version): \(.versions[0].broken_on[0].code)"' site/index.json
fi
- name: The site
run: |
cp -R icons site/icons
+5 -3
View File
@@ -11,9 +11,10 @@ with no file, network or clock access, and can only do what their page lists.
Native source mods (`.hnmod`, kind `native-mod`) are built into an isolated game by
a launcher with native-mod support. They run with the player’s account permissions.
Their bundles pin the exact Horizon and Horizon Mixins revisions used for the build.
Other revisions have not been validated. Registry checks inspect the source bundle without
executing native code; reviewers must also examine the source and pinned-build
validation. Publishing these requires the native-mod changes to `cargo-horizon-mod`
Other revisions have not been validated. Registry checks inspect the source bundle and compile it
on the newest Nightly with the most downloaded native mods (in a job whose token
can only read) without running it; reviewers must also examine the source and
pinned-build validation. Publishing these requires the native-mod changes to `cargo-horizon-mod`
and the launcher to have shipped first.
**Who is responsible.** Except the projects marked Official (made by the Horizon project), everything here is made and
@@ -65,6 +66,7 @@ why.
| `.github/workflows/check.yml` | A pull request's checks |
| `.github/workflows/publish.yml` | On merge: releases, then `index.json` on Pages |
| `.github/workflows/cleanup.yml` | Weekly: staging files nobody needs any more |
| `.github/workflows/native-compat.yml` | Native mods on the newest Nightly, as the launcher builds them: a pull request's new version applied and compiled with the most downloaded native mods, its module names against every mod's; daily, every mod's newest version, the ones that don't apply marked `broken_on` in the index (`scripts/native_compat.py`) |
| `.github/workflows/native.yml` | Horizon's native mods: each changed one's tests, `.hnmod` and game check, all of them together, and weekly on the newest Nightly |
| `native/<name>/` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD): the source of their `.hnmod` files |
| `native/tools/` | Shared by them: apply on Horizon, check, build, package, prove (`native/tools/native.py`) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+13
View File
@@ -24,6 +24,19 @@ categories = ["utility", "food"]
tags = ["hud", "hunger", "saturation", "food", "port"]
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
[[versions]]
version = "0.1.3"
published = "2026-11-05"
game = "26.3"
file = "appleskin-0.1.3.hnmod"
sha256 = "2dac91d4c4c4ab65684f41a3279c6b27beffa7407e5362b0d7a97c28c07914a6"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVGmizdOQdqc3jXpjW/MxBBP/JE8iFYmkrE0cKbEOUP5t11senfkM2hkDW2JLSOEJCfQJT7PMS/LnYbXET1ux0Ag=
trusted comment: timestamp:1793891067\tfile:appleskin-0.1.3.hnmod\thashed
r7jcpfe0pAz6c2FZAuAd6EUJojhQ56mGoq/H9hYXyC8rZ6QuwErQt/nEM4ly1VjMIKS7pbn6vCz0wK7jIak+DQ=="""
changelog = "Fix for https://github.com/horizon-rust/horizon-mods/issues/289"
[[versions]]
version = "0.1.2"
published = "2026-11-02"
+43
View File
@@ -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"""
+2 -2
View File
@@ -117,8 +117,8 @@ the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_sta
**Vanish.** `/vanish` takes an operator of level 3 out of the Tab list and the world of every player below level 3,
whatever the game mode (survival and creative too), and those players don't read them joining or leaving; operators of
level 3 or more still see them, and are told who vanished. A vanished operator reads a subtitle the whole time
(*Vanished: only operators see you*), isn't counted online (the sidebar, the Tab list's footer), doesn't hold the night up, isn't
level 3 or more still see them, and are told who vanished. A vanished operator reads a line above the hotbar the
whole time (*Vanished: only operators see you*), isn't counted online (the sidebar, the Tab list's footer), doesn't hold the night up, isn't
announced as AFK or on a first join, and can't be reached by name by those who don't see them (`/tpa`, `/trade`,
`/coinflip`, `/playtime`). It lasts over a relog and a restart (`vanish.json`) until `/vanish` again. Needs
horizon#7003 (`native_hide_player`).
+7 -11
View File
@@ -2682,22 +2682,18 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
game.native_sleep_ignored(uuid, ignored);
}
}
// A vanished player doesn't hold the night up; the subtitle lasts a
// little past the next second's (1.5 s), so it never blinks.
// A vanished player doesn't hold the night up. The reminder is on the
// action bar, above the hotbar, not in the middle of the screen: sent once
// a second, it stays up (an action bar line lasts about 3 s), so it never blinks.
for (uuid, name) in subtitles {
game.native_sleep_ignored(uuid, true);
let subtitle = serde_json::json!({"text": smp_vanish::SUBTITLE, "color": "aqua"});
for line in [
format!("title {name} times 0 30 5"),
format!("title {name} subtitle {subtitle}"),
format!("title {name} title \"\""),
] {
game.native_run_command(&line);
}
let line = serde_json::json!({"text": smp_vanish::SUBTITLE, "color": "aqua"});
game.native_run_command(&format!("title {name} actionbar {line}"));
}
for (uuid, name) in unvanished {
game.native_sleep_ignored(uuid, false);
game.native_run_command(&format!("title {name} clear"));
// An empty line takes the reminder down at once, and leaves any title alone.
game.native_run_command(&format!("title {name} actionbar \"\""));
}
for line in particles.into_iter().chain(casino) {
game.native_run_command(&line);
+12 -9
View File
@@ -1068,27 +1068,30 @@ def trades(server, alex, steve, port, ctl, resources, root):
def vanish(server, alex, steve, out):
"""/vanish: Alex (an operator) leaves Steve's world and Tab list, and reads a subtitle the whole time; Steve can't
"""/vanish: Alex (an operator) leaves Steve's world and Tab list, and reads a line above the hotbar the whole time; Steve can't
reach her by name; /vanish again brings her back. Steve can't vanish."""
r = {}
players = lambda c: [e for e in c.run("entities", 64, "player")["entities"]]
subtitle = lambda c: c.run("messages", 1).get("subtitle") or ""
actionbar = lambda c: c.run("messages", 1).get("actionbar") or ""
centre = lambda c: (c.run("messages", 1).get("subtitle") or "") + (c.run("messages", 1).get("title") or "")
r["seen_before"] = len(players(steve))
r["refused"] = steve.say("/vanish")
assert any("Unknown" in l or "Only operators" in l or "permission" in l for l in r["refused"]), r
r["vanish"] = alex.say("/vanish")
assert any("You vanished" in l for l in r["vanish"]), r
time.sleep(2.5)
# Over a few seconds, not just once: the subtitle never lapses.
r["subtitles"] = []
# Over a few seconds, not just once: the line never lapses.
r["actionbar"] = []
for _ in range(4):
r["subtitles"].append(subtitle(alex))
r["actionbar"].append(actionbar(alex))
time.sleep(1)
assert all(s == "Vanished: only operators see you" for s in r["subtitles"]), r
assert "Vanished" not in subtitle(steve), r
assert all(s == "Vanished: only operators see you" for s in r["actionbar"]), r
# On the action bar only: nothing in the middle of the screen.
assert "Vanished" not in centre(alex), r
assert "Vanished" not in actionbar(steve), r
if r["seen_before"]:
assert not players(steve), "Steve no longer sees Alex"
alex.run("screenshot", str(Path(out).resolve() / "vanish-subtitle.png"))
alex.run("screenshot", str(Path(out).resolve() / "vanish-actionbar.png"))
steve.run("key", "tab", "down")
time.sleep(1)
steve.run("screenshot", str(Path(out).resolve() / "vanish-tab.png"))
@@ -1100,7 +1103,7 @@ def vanish(server, alex, steve, out):
r["back"] = alex.say("/vanish")
assert any("You are visible again." in l for l in r["back"]), r
time.sleep(3)
assert "Vanished" not in subtitle(alex), r
assert "Vanished" not in actionbar(alex), r
if r["seen_before"]:
assert players(steve), "Steve sees Alex again"
time.sleep(6) # the spam guard's window, for the steps after
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.
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""Whether native mods build on the newest Nightly, together, as a player's launcher builds them.
python3 scripts/native_compat.py check BASE HEAD # a pull request's new native versions
python3 scripts/native_compat.py health --out health.json # every native mod's newest version
`check` takes each native-mod version HEAD lists and BASE doesn't, and fails when:
- one of its support modules has the name, in the same file, of another registry mod's (the launcher
can't build both: E_MODULE_CONFLICT);
- horizon-mixins doesn't apply it on the newest Nightly with the most downloaded native mods
(`--popular`), as the launcher moves mods on the hook points to the Horizon it builds;
- the game doesn't compile (`cargo check`) with it and those mods, when it does without it.
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 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.
"""
import argparse
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tomllib
import zipfile
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"
MIXINS_GIT = "https://github.com/horizon-rust/horizon-mixins"
WORK = native.TARGET / "compat"
def log(*args):
native.log(*args)
class Bundle:
"""An unpacked `.hnmod`: its mixin bundle in WORK/mods/<set>/<id>/."""
def __init__(self, data, origin):
archive = zipfile.ZipFile(io.BytesIO(data))
meta = json.loads(archive.read("horizon-native.json"))
self.id, self.version, self.origin = meta["id"], meta["version"], origin
self.mixins = meta.get("mixins")
prefix = f"mixins/{self.id}/"
self.files = {n[len(prefix):]: archive.read(n) for n in archive.namelist()
if n.startswith(prefix) and not n.endswith("/")}
manifest = tomllib.loads(self.files["horizon-mixin.toml"].decode())
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
for name, data in self.files.items():
path = out / name
if ".." in Path(name).parts:
sys.exit(f"{self.id}: {name} leaves its folder")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
# As the launcher does for a mod built on another Horizon than it pins.
toml = out / "horizon-mixin.toml"
text = re.sub(r'horizon_revision\s*=\s*"[0-9a-f]{40}"', f'horizon_revision = "{revision}"', toml.read_text())
toml.write_text(text)
def download(url, sha256):
cache = WORK / "files" / sha256
if not cache.is_file():
data = native.get(url)
if hashlib.sha256(data).hexdigest() != sha256:
sys.exit(f"{url}: its SHA-256 isn't the index's")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(data)
return cache.read_bytes()
def published():
"""The newest version of every native mod in the published index, most downloaded first."""
index = json.loads(native.get(INDEX))
revoked = {r.get("sha256") for r in index.get("revoked", [])}
found = []
for project in sorted(index["projects"], key=lambda p: -p.get("downloads", 0)):
if project.get("kind") != "native-mod":
continue
version = next((v for v in project["versions"] if v["file"]["sha256"] not in revoked), None)
if version:
found.append(Bundle(download(version["file"]["url"], version["file"]["sha256"]), "registry"))
return found
def mixins_tool(bundles):
"""horizon-mixins as the launcher picks it: the newest of its own (Horizon main's MIXINS_REV) and the
mods' pins, by commit date."""
text = native.get(f"https://raw.githubusercontent.com/{native.REPO}/main/launcher/src/native_mods.rs").decode()
revs = {re.search(r'pub const MIXINS_REV: &str = "([0-9a-f]{40})"', text).group(1)}
revs |= {b.mixins for b in bundles if b.mixins}
def date(rev):
commit = json.loads(native.get(f"https://api.github.com/repos/horizon-rust/horizon-mixins/commits/{rev}"))
return commit["commit"]["committer"]["date"]
rev = max(revs, key=date)
root = native.TARGET / "horizon-mixins" / rev
binary = root / "bin" / "horizon-mixins"
if not binary.is_file():
log(f"Building horizon-mixins {rev[:7]}")
subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", MIXINS_GIT, "--rev", rev,
"--root", str(root), "horizon-mixins-cli"], check=True)
return binary
def culprit(diagnostic, bundles):
"""The bundle a horizon-mixins diagnostic names: by mod id, its manifest's path, its module's name or
destination; between two mods' modules, the later one (the launcher's rule)."""
by_id = {b.id: b for b in bundles}
if diagnostic.get("mod_id") in by_id:
return by_id[diagnostic["mod_id"]]
file = diagnostic.get("file", "")
for b in bundles:
if f"/{b.id}/horizon-mixin.toml" in file:
return b
named = re.search(r"support module '([^']+)'", diagnostic.get("message", ""))
declaring = [b for b in bundles if any((named and n == named.group(1)) or f == file for f, n in b.modules)]
return declaring[-1] if declaring else None
def apply(tool, tree, bundles, revision, name):
"""horizon-mixins on `bundles` together, leaving out each one it refuses (and the ones needing it) until
the rest applies: (stage folder or None, {id: diagnostic})."""
left, refused = list(bundles), {}
while left:
mods = WORK / "mods" / name
shutil.rmtree(mods, ignore_errors=True)
mods.mkdir(parents=True)
for b in left:
b.unpack(mods, revision)
out = WORK / "stage" / name
shutil.rmtree(out, ignore_errors=True)
run = subprocess.run([str(tool), "apply", "--workspace", str(tree), "--mods", str(mods), "--out", str(out)],
capture_output=True, text=True)
if run.returncode == 0:
return out, refused
text = run.stderr + run.stdout
try:
diagnostic = json.loads(text[text.index("{"):text.rindex("}") + 1])
except ValueError:
sys.exit(f"horizon-mixins failed without a diagnostic:\n{text[-2000:]}")
bad = culprit(diagnostic, left)
if bad is None:
sys.exit(f"horizon-mixins refused the set, naming no mod: {json.dumps(diagnostic)}")
log(f" {bad.id} {bad.version}: {diagnostic.get('code')}: {diagnostic.get('message')}")
refused[bad.id] = diagnostic
gone = {bad.id}
while True:
more = [b 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 b in more:
refused[b.id] = {"code": "E_DEPENDENCY", "message": f"needs {', '.join(b.needs)}"}
gone.add(b.id)
left = [b for b in left if b.id not in gone]
return None, refused
# 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(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):
"""(id, version, file, sha256) of each native-mod version HEAD lists and BASE doesn't."""
def versions(root):
out = {}
for path in sorted((Path(root) / "mods").glob("*.toml")):
entry = tomllib.loads(path.read_text())
if entry.get("kind") == "native-mod":
for v in entry.get("versions", []):
out[(entry["id"], v["version"])] = (v["file"], v["sha256"])
return out
before = versions(base)
return [(i, v, f, s) for (i, v), (f, s) in versions(head).items() if (i, v) not in before]
def new_versions(base, head):
"""The native-mod versions HEAD lists that BASE doesn't: their file from its release once published, else
from the `staging` prerelease the submission form uploads it to (scripts/fetch.sh)."""
found = []
for id, version, file, sha in new_entries(base, head):
releases = f"https://github.com/{REGISTRY}/releases/download"
try:
data = download(f"{releases}/{id}-{version}/{file}", sha)
except Exception:
data = download(f"{releases}/staging/{sha}.hnmod", sha)
found.append(Bundle(data, "submitted"))
return found
def check(args):
submitted = new_versions(args.base, args.head)
if not submitted:
print("No new native mod version.")
return 0
registry = published()
revision = native.latest_nightly() if args.horizon == "latest" else args.horizon
tree = native.source(revision)
failed = []
for mod in submitted:
others = [b for b in registry if b.id != mod.id]
print(f"## {mod.id} {mod.version} on Horizon {revision[:7]} (the newest Nightly)")
clashes = sorted({(b.id, f, n) for b in others for f, n in mod.modules & b.modules})
for other, file, name in clashes:
print(f"- support module `{name}` in `{file}` is also {other}'s: the launcher can't build both."
f" Name it after the mod (`{mod.id}_…`).")
popular = others[:args.popular]
tool = mixins_tool([mod, *popular])
stage, refused = apply(tool, tree, [mod, *popular], revision, f"{mod.id}-together")
for other, diagnostic in refused.items():
if other != mod.id:
print(f"- {other} (popular) doesn't apply on this Nightly either: {diagnostic.get('code')}."
" The launcher leaves it out.")
if mod.id in refused:
d = refused[mod.id]
print(f"- **doesn't apply**: `{d.get('code')}`: {d.get('message')}"
+ (f" (injection `{d['injection_id']}`)" if d.get("injection_id") else ""))
failed.append(mod.id)
continue
if clashes:
failed.append(mod.id)
if args.no_compile:
continue
if compiles(stage):
print(f"- applies and compiles with {len(popular) - len(refused)} popular mods.")
continue
alone, _ = apply(tool, tree, [mod], revision, f"{mod.id}-alone")
without, _ = apply(tool, tree, [b for b in popular if b.id not in refused], revision, f"{mod.id}-without")
if not compiles(alone):
print("- **doesn't compile** on this Nightly, even alone (the errors are in the log).")
failed.append(mod.id)
elif without is None or compiles(without):
print("- **doesn't compile together with the popular mods**, while each side compiles alone.")
failed.append(mod.id)
else:
print("- compiles alone; the popular mods already don't compile together on this Nightly.")
return 1 if failed else 0
def health(args):
registry = published()
revision = native.latest_nightly() if args.horizon == "latest" else args.horizon
tree = native.source(revision)
tool = mixins_tool([])
_, 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": 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(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
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="action", required=True)
c = sub.add_parser("check")
c.add_argument("base")
c.add_argument("head")
c.add_argument("--popular", type=int, default=20)
c.add_argument("--no-compile", action="store_true")
n = sub.add_parser("new", help="list the new native-mod versions, one per line")
n.add_argument("base")
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()
if args.action == "new":
for id, version, _, _ in new_entries(args.base, args.head):
print(id, version)
return
sys.exit(check(args) if args.action == "check" else health(args))
if __name__ == "__main__":
main()
+191
View File
@@ -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
+110
View File
@@ -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()