Files
RustNotMC/crates/horizon-mods/tests/api_reference.rs
T
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

340 lines
13 KiB
Rust

//! docs/api/mods-reference.md, generated from the WIT contract and the manifest's capabilities, and a test that
//! fails when it is out of date. Update it with:
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference`.
use horizon_mods::manifest::{APIS, Capability};
use std::{fmt::Write, path::Path};
use wit_parser::{Docs, Function, Handle, Resolve, Type, TypeDefKind, WorldItem};
const WIT: &str = "crates/horizon-mod-sdk/wit/horizon.wit";
const DOC: &str = "docs/api/mods-reference.md";
fn root() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
}
/// A doc comment on one line, for a table cell.
fn cell(docs: &Docs) -> String {
let text = docs.contents.as_deref().unwrap_or("").trim();
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace('|', "\\|")
}
/// A type as WIT spells it.
fn ty(resolve: &Resolve, t: &Type) -> String {
let id = match t {
Type::Bool => return "bool".into(),
Type::U8 => return "u8".into(),
Type::U16 => return "u16".into(),
Type::U32 => return "u32".into(),
Type::U64 => return "u64".into(),
Type::S8 => return "s8".into(),
Type::S16 => return "s16".into(),
Type::S32 => return "s32".into(),
Type::S64 => return "s64".into(),
Type::F32 => return "f32".into(),
Type::F64 => return "f64".into(),
Type::Char => return "char".into(),
Type::String => return "string".into(),
Type::ErrorContext => return "error-context".into(),
Type::Id(id) => *id,
};
match &resolve.types[id].name {
Some(name) => name.clone(),
None => definition(resolve, &resolve.types[id].kind),
}
}
/// What an anonymous type, or the right-hand side of a `type` alias, spells.
fn definition(resolve: &Resolve, kind: &TypeDefKind) -> String {
let of = |t: &Option<Type>| t.as_ref().map_or("_".into(), |t| ty(resolve, t));
match kind {
TypeDefKind::Option(t) => format!("option<{}>", ty(resolve, t)),
TypeDefKind::List(t) => format!("list<{}>", ty(resolve, t)),
TypeDefKind::FixedLengthList(t, n) => format!("list<{}, {n}>", ty(resolve, t)),
TypeDefKind::Result(r) => match (&r.ok, &r.err) {
(None, None) => "result".into(),
(ok, None) => format!("result<{}>", of(ok)),
(ok, err) => format!("result<{}, {}>", of(ok), of(err)),
},
TypeDefKind::Tuple(t) => {
let items: Vec<_> = t.types.iter().map(|t| ty(resolve, t)).collect();
format!("tuple<{}>", items.join(", "))
}
TypeDefKind::Future(t) => t
.as_ref()
.map_or("future".into(), |t| format!("future<{}>", ty(resolve, t))),
TypeDefKind::Stream(t) => t
.as_ref()
.map_or("stream".into(), |t| format!("stream<{}>", ty(resolve, t))),
TypeDefKind::Handle(Handle::Own(t)) => ty(resolve, &Type::Id(*t)),
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(resolve, &Type::Id(*t))),
TypeDefKind::Type(t) => ty(resolve, t),
other => panic!(
"{WIT}: an anonymous {} the reference can't spell",
other.as_str()
),
}
}
fn signature(resolve: &Resolve, f: &Function) -> String {
let params: Vec<_> = f
.params
.iter()
.map(|p| format!("{}: {}", p.name, ty(resolve, &p.ty)))
.collect();
let result = f
.result
.as_ref()
.map_or(String::new(), |t| format!(" -> {}", ty(resolve, t)));
format!("func({}){result}", params.join(", "))
}
/// Who may use an interface: the capability that grants it, or what it is.
fn access(interface: &str, exported: bool) -> String {
if exported {
return "exported by the mod: the game calls it".into();
}
if interface == "log" {
return "always granted".into();
}
match Capability::ALL.iter().find(|c| c.interface() == interface) {
Some(c) => format!("capability `{c}`"),
None => "not granted by any capability".into(),
}
}
fn markdown() -> String {
let mut resolve = Resolve::new();
let package = resolve
.push_file(root().join(WIT))
.expect("the WIT contract parses");
let package = &resolve.packages[package];
let name = &package.name;
let mut out = String::new();
let w = &mut out;
writeln!(w, "<!-- Generated from {WIT} and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->").unwrap();
writeln!(w, "# Mod API reference\n").unwrap();
writeln!(
w,
"The contract between Horizon and its mods: the WIT package `{name}`, every interface, type, function and hook in it, \
and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: \
[stability.md](stability.md). Manifest `api` lines this game serves: {}.\n",
APIS.map(|a| format!("`{a}`")).join(", ")
)
.unwrap();
writeln!(w, "## Capabilities\n").unwrap();
writeln!(
w,
"| In `capabilities` | Grants | The player reads \"May …\" |\n| --- | --- | --- |"
)
.unwrap();
for c in Capability::ALL {
let grants = if package.interfaces.contains_key(c.interface()) {
format!("[`{0}`](#{0})", c.interface())
} else {
"data files (`assets/`, `data/`), no interface".into()
};
writeln!(w, "| `{c}` | {grants} | {} |", c.describe()).unwrap();
}
let mut exported = Vec::new();
for (world_name, &world) in &package.worlds {
let world = &resolve.worlds[world];
let prefix = format!("{}:{}/", name.namespace, name.name);
let names = |keys: Vec<&wit_parser::WorldKey>| {
keys.into_iter()
.map(|key| {
let full = resolve.name_world_key(key);
let short = full.trim_start_matches(&prefix);
format!("`{}`", short.split('@').next().unwrap_or(short))
})
.collect::<Vec<_>>()
.join(", ")
};
writeln!(w, "\n## World `{world_name}`\n").unwrap();
if world.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&world.docs)).unwrap();
}
writeln!(
w,
"- Imports (what the game offers): {}",
names(world.imports.keys().collect())
)
.unwrap();
writeln!(
w,
"- Exports (what the mod provides): {}",
names(world.exports.keys().collect())
)
.unwrap();
for item in world.exports.values() {
if let WorldItem::Interface { id, .. } = item {
exported.push(*id);
}
}
}
for (interface_name, &id) in &package.interfaces {
let interface = &resolve.interfaces[id];
writeln!(w, "\n## `{interface_name}`\n").unwrap();
writeln!(
w,
"Access: {}.\n",
access(interface_name, exported.contains(&id))
)
.unwrap();
if interface.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&interface.docs)).unwrap();
}
for (type_name, &type_id) in &interface.types {
let def = &resolve.types[type_id];
writeln!(w, "### {} `{type_name}`\n", def.kind.as_str()).unwrap();
if def.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&def.docs)).unwrap();
}
match &def.kind {
TypeDefKind::Record(r) => {
writeln!(w, "| Field | Type | |\n| --- | --- | --- |").unwrap();
for f in &r.fields {
writeln!(
w,
"| `{}` | `{}` | {} |",
f.name,
ty(&resolve, &f.ty),
cell(&f.docs)
)
.unwrap();
}
}
TypeDefKind::Enum(e) => {
writeln!(w, "| Case | |\n| --- | --- |").unwrap();
for c in &e.cases {
writeln!(w, "| `{}` | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Variant(v) => {
writeln!(w, "| Case | Payload | |\n| --- | --- | --- |").unwrap();
for c in &v.cases {
let payload =
c.ty.as_ref()
.map_or(String::new(), |t| format!("`{}`", ty(&resolve, t)));
writeln!(w, "| `{}` | {payload} | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Flags(f) => {
writeln!(w, "| Flag | |\n| --- | --- |").unwrap();
for flag in &f.flags {
writeln!(w, "| `{}` | {} |", flag.name, cell(&flag.docs)).unwrap();
}
}
TypeDefKind::Resource => {
writeln!(w, "A resource: the game owns it, the mod holds a handle.").unwrap()
}
kind => writeln!(w, "`type {type_name} = {}`", definition(&resolve, kind)).unwrap(),
}
writeln!(w).unwrap();
}
if !interface.functions.is_empty() {
writeln!(
w,
"| Function | Signature | What it does |\n| --- | --- | --- |"
)
.unwrap();
for (fn_name, f) in &interface.functions {
writeln!(
w,
"| `{fn_name}` | `{}` | {} |",
signature(&resolve, f),
cell(&f.docs)
)
.unwrap();
}
}
}
out
}
#[test]
fn the_mod_api_reference_is_up_to_date() {
let path = root().join(DOC);
let want = markdown();
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
std::fs::write(&path, &want).unwrap();
}
let have = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
assert!(
have == want,
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference"
);
}
#[test]
fn every_function_is_documented() {
let mut resolve = Resolve::new();
resolve.push_file(root().join(WIT)).unwrap();
for (_, interface) in resolve.interfaces.iter() {
for (name, f) in &interface.functions {
assert!(
f.docs.contents.is_some(),
"{WIT}: `{name}` needs a doc comment: the reference shows it to mod authors"
);
}
}
}
#[test]
fn every_capability_with_an_interface_is_in_the_contract() {
let mut resolve = Resolve::new();
let package = resolve.push_file(root().join(WIT)).unwrap();
let interfaces = &resolve.packages[package].interfaces;
for c in Capability::ALL {
if c != Capability::Registries {
assert!(
interfaces.contains_key(c.interface()),
"capability {c} grants `{}`, missing from {WIT}",
c.interface()
);
}
}
}
/// Every function the game offers (an interface some world imports and none
/// exports) has an API level, and the levels list nothing else.
#[test]
fn every_function_has_an_api_level() {
let mut resolve = Resolve::default();
let package = resolve.push_file(root().join(WIT)).unwrap();
let package = &resolve.packages[package];
let exported: Vec<_> = package
.worlds
.values()
.flat_map(|&w| resolve.worlds[w].exports.values())
.filter_map(|item| match item {
WorldItem::Interface { id, .. } => Some(*id),
_ => None,
})
.collect();
let mut offered = Vec::new();
for (name, &id) in &package.interfaces {
if !exported.contains(&id) {
for function in resolve.interfaces[id].functions.keys() {
offered.push(format!("{name}.{function}"));
}
}
}
for f in &offered {
assert!(
horizon_mods::api_level::level_of(f).is_some(),
"{f} has no level in crates/horizon-mods/src/api_level.rs: add it at the next level"
);
}
for (f, _) in horizon_mods::api_level::FUNCTIONS {
assert!(offered.iter().any(|o| o == f), "{f} isn't in the WIT");
}
}