diff --git a/magiclib-core/README.md b/magiclib-core/README.md new file mode 100644 index 00000000..7a0be620 --- /dev/null +++ b/magiclib-core/README.md @@ -0,0 +1,254 @@ +# MagicLib Core + +The MagicLib Core module, providing dependency checking, event management, I18n support, Mixin enhancements, and an abstract loader implementation. + +## Features + +- [Dependency Check](#dependency-check) + +## Dependency Check + +### Background + +This feature was originally designed by [plusls](https://github.com/plusls) for his personal mods, aiming at a simple and flexible way of checking dependencies. It was later moved into MagicLib for easier maintenance and code reuse. Since then, inspired by [Fallen-Breath](https://github.com/Fallen-Breath)'s [conditional-mixin](https://github.com/Fallen-Breath/conditional-mixin), the module has been improved further. + +### What it does + +MagicLib provides an annotation based dependency check framework. Even in complex code, you can declare conditions on semantic version numbers, runtime distributions and loader platforms; besides, you can use predicates for even more complex checks. + +This module fills the gaps of the dependency checks shipped with the loaders: for example, a missing optional dependency can pass softly instead of hard failing, and the same interface works on every loader. + +Three entry points consume the declarations — the entry point check, the mixin check, and the programmatic `DependencyChecker` API. They share one set of annotations and one set of group semantics, and differ only in what they do with the result and which object a predicate receives. + +#### Entry point dependency check + +Before Minecraft starts, MagicLib scans the dependency declarations on the entry point methods of every loaded mod and checks whether they are satisfied; an unsatisfied check aborts the Minecraft startup and lists every failed entry in a separate window. + +The check runs from the Fabric `PreLaunchEntrypoint` of MagicLib and from the `@Mod` constructor on Forge-Like platforms, so it happens before the game window appears. It runs exactly once; triggering it again throws an `IllegalStateException`. + +On the client MagicLib scans `onInitialize` and `onInitializeClient`, while on the server it scans `onInitialize` and `onInitializeServer`. The declarations found on both methods are pooled into one list, which makes them alternatives: the mod passes when either method passes. + +We do not recommend distribution (`DistType`) checks on environment specific entry point methods — e.g. declaring `DistType.CLIENT` on `onInitializeServer` makes the check always fail. + +Which classes get scanned depends on the platform: + +- **Fabric-Like**: the entry point classes declared under the `main`, `client` and `server` keys that implement `ModInitializer`, `ClientModInitializer` or `DedicatedServerModInitializer`. Only the plain class form is supported — an entry point declared as `Class::method` is skipped. +- **Forge-Like**: the `@Mod` annotated classes that implement `top.hendrixshen.magiclib.api.entrypoint.ModInitializer`, the Fabric style initializer MagicLib provides for cross-platform mods. A class without that interface is not scanned at all. + +Only the first method matching a name with the `()V` descriptor is read, so an overloaded entry point method keeps a single declaration. + +#### Mixin dependency check + +The `MagicMixinPlugin` shipped by MagicLib integrates the dependency check: a mixin is checked when it is applied at runtime, and an unsatisfied mixin is simply not applied. Note that the mixin dependency check does not support method level or field level declarations — that would be unsafe at runtime. Declare on the mixin class instead. + +Results are cached per mixin class name by the memoized checker that `MagicMixinPlugin` uses, so a mixin is evaluated once no matter how many target classes it declares. A mixin whose class node cannot be read is not applied either. + +A failed mixin does not stop the game. Its reason is handed to the check failure callback, and `MagicMixinPlugin` logs it as a warning only while the mixin `DEBUG_EXPORT` environment option is enabled. + +#### Programmatic check + +`DependencyChecker` applies the same rules to your own code, from a `ClassNode`, `MethodNode`, `FieldNode` or a reflected `Field`: + +```java +public static void check(Field field, Object obj) { + DependencyCheckResult result = DependencyChecker.check(field, obj); + + if (!result.isSuccess()) { + MagicLib.getLogger().warn("Dependencies not satisfied: {}", result.getReason()); + } +} +``` + +An element without declarations yields a successful result whose reason is `null`; a failing one yields a message tree that lists only the sections which actually failed. + +### How it works + +Conditions are organized in groups, each group being wrapped by `@Dependencies`: the groups are alternatives (or) — any passing group is enough — while inside a group every condition must pass (and). Repeating `@Dependencies` on one element is equivalent to bundling the groups in `@CompositeDependencies`. + +An element without any dependency declaration is always satisfied. + +Inside a group, every `require` entry must pass and no `conflict` entry may be triggered: a conflict entry that matches fails the whole group. + +`dependencyType` selects what a condition reads and what makes it pass: + +| Type | Attributes read | Passes as `require` when | +|--------------------|------------------------------------------|-------------------------------------------------------------| +| `MOD_ID` (default) | `value`, `versionPredicates`, `optional` | the mod is loaded and its version satisfies every predicate | +| `DIST` | `distType` | the current distribution matches | +| `PLATFORM` | `platformType` | the current loader platform matches | +| `PREDICATE` | `predicate` | your predicate returns `true` for the checked object | + +`optional` only affects the mod (`MOD_ID`) checks: the entry passes when the mod is missing, and the version predicates are only verified while the mod is loaded — a mismatching version still fails. Used as a `conflict` entry, `optional` is ignored; the conflict is triggered when the mod is loaded with a matching version, and stays untriggered when the mod is missing or its version does not match. + +`DistType.ANY` and `PlatformType.ANY` match everything, while `PlatformType.UNKNOWN` matches nothing. `FABRIC_LIKE` covers `FABRIC` and `QUILT`, and `FORGE_LIKE` covers `FORGE` and `NEOFORGE`. + +A predicate entry executes your own code against the object being checked, and that object depends on the entry point: the target class `ClassNode` for mixins, the `ConfigContainer` for config options, and `null` for entry point methods. The predicate class needs a no-argument constructor, is instantiated once per parsed declaration, and must not be an interface — write predicates that tolerate a null argument when they may be shared across entry points. + +The full attribute semantics live in the annotation javadocs, so they are not repeated here. + +### Examples + +#### Apply a Mixin only when a matching mod version is installed + +The mixin below is applied only when `modmenu` 20.0.0 or newer is loaded. + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0")) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Apply a Mixin only when a mod is not installed + +The mixin below is applied only when `modmenu` is not loaded. + +```java +@Dependencies(conflict = @Dependency("modmenu")) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Apply a Mixin when a mod is missing or a matching version is installed + +The mixin below is applied when `modmenu` is missing, or when a version 20.0.0 or newer is loaded. + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0", optional = true)) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Restrict a Mixin to one loader platform + +The mixin below is applied on Fabric and Quilt only. + +```java +@Dependencies(require = @Dependency( + dependencyType = DependencyType.PLATFORM, + platformType = PlatformType.FABRIC_LIKE +)) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Match a version range + +The mixin below is applied when the loaded `modmenu` is at least 20.0.0 but older than 21.0.0. Several predicates in one string are space separated, and every entry of the array must match. + +```java +@Dependencies(require = @Dependency( + value = "modmenu", + versionPredicates = {">=20.0.0", "<21.0.0"} +)) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Combined conditions (and) + +The mixin below is applied only when both `mod_a` and `mod_b` are loaded. + +```java +@Dependencies( + require = { + @Dependency("mod_a"), + @Dependency("mod_b") + } +) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Composite conditions (or) + +The mixin below is applied when either `mod_a` or `mod_b` is loaded. + +```java +@Dependencies(require = @Dependency(value = "mod_a")) +@Dependencies(require = @Dependency(value = "mod_b")) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // Your code +} +``` + +#### Custom predicate check + +Point the dependency at a predicate class that provides a no-argument constructor. For a mixin the checked object is the `ClassNode` of the target class, which is what `MixinPredicate` binds. + +```java +public class MyPredicate implements MixinPredicate { + @Override + public boolean test(ClassNode classNode) { + return classNode.superName != null; + } +} +``` + +```java +@Dependencies(require = @Dependency( + dependencyType = DependencyType.PREDICATE, + predicate = MyPredicate.class +)) +public class PredicatedMixin { +} +``` + +#### Entry point dependency check + +The entry point below prevents Minecraft from starting when `fabric-api` is missing. On Forge-Like platforms the class must also implement `top.hendrixshen.magiclib.api.entrypoint.ModInitializer` to be scanned. + +```java +public class MyModInitializer implements ModInitializer { + @Dependencies(require = @Dependency(value = "fabric-api")) + @Override + public void onInitialize() { + // Your code + } +} +``` + +#### Check the declaration on a field + +The field below carries its own declaration, which `DependencyChecker` evaluates on demand. The second argument is the object a predicate would receive, so `null` is fine while no predicate is declared. + +```java +public class MyFeatures { + @Dependencies(conflict = @Dependency("sodium")) + public static final boolean LEGACY_RENDERER = true; + + public static void init() throws NoSuchFieldException { + Field field = MyFeatures.class.getDeclaredField("LEGACY_RENDERER"); + + if (!DependencyChecker.check(field, null).isSuccess()) { + return; + } + + // Your code + } +} +``` + +### Notes + +A version predicate is an operator followed by a version, supporting `>=`, `<=`, `>`, `<`, `=`, `~` and `^`; separate several conditions with a space to require all of them, for example `>=1.16` or `>=1.16 <1.17`. `~` keeps the major and the minor component, `^` keeps the major one. + +A trailing wildcard may replace the last component — `1.16.x`, `1.16.X` and `1.16.*` all mean `~1.16`, and `1.x` means `^1`. A wildcard needs the equality operator or no operator at all, extra trailing wildcards are dropped, and pre-release versions may not use wildcards. `*` alone, or no predicate at all, matches every version. + +The version being tested must parse as a semantic version. When the version of a loaded mod does not, that check fails and an error is logged; when the version written in the predicate does not, operators that exclude both bounds (`>` and `<`) are rejected and every other operator degrades to plain string equality. + +`@Dependency` declares no target of its own, so it can only appear nested inside `@Dependencies`. Both `@Dependencies` and `@CompositeDependencies` can annotate a type, a field or a method, but which of them an entry point actually reads is up to that entry point. + +The entry point check runs for the entry point classes of every loaded mod before the game window appears, and aborts startup on failure. The mixin check never aborts anything: it only decides whether one mixin is applied. diff --git a/magiclib-core/README_ZH_CN.md b/magiclib-core/README_ZH_CN.md new file mode 100644 index 00000000..75ff339a --- /dev/null +++ b/magiclib-core/README_ZH_CN.md @@ -0,0 +1,254 @@ +# MagicLib Core + +MagicLib Core 模块,提供依赖检查、事件管理、I18n 支持、Mixin 增强功能以及抽象加载器的实现。 + +## 功能 + +- [依赖检查](#依赖检查) + +## 依赖检查 + +### 背景 + +该功能最初由 [plusls](https://github.com/plusls) 为其个人模组设计,旨在提供一种简单且灵活的依赖检查方式;为便于维护与复用代码,随后被迁移至 MagicLib。在此之后,受 [Fallen-Breath](https://github.com/Fallen-Breath) 的 [conditional-mixin](https://github.com/Fallen-Breath/conditional-mixin) 启发,该模块又得到了进一步的改进。 + +### 功能简介 + +MagicLib 提供注解风格的依赖检查框架。即使在复杂的代码中,您也可以声明语义版本号、运行环境与加载器环境等条件;此外,您还可以通过谓词实现更为复杂的检查。 + +该模块弥补了各加载器自带依赖检查的不足之处:例如,可选依赖缺失时检查能够软性通过(不会导致硬失败),并且在不同加载器上提供一致的接口。 + +共有三个入口点会消费这些声明——入口点检查、mixin 检查,以及编程式的 `DependencyChecker` API。它们共用同一套注解与同一套分组语义,区别仅在于如何处理检查结果,以及谓词会收到什么对象。 + +#### 入口点依赖检查 + +在 Minecraft 启动之前,MagicLib 会扫描所有已加载模组的入口点方法上的依赖声明并检查其是否满足;检查不通过将终止 Minecraft 启动,并在一个独立窗口中展示所有失败的条目。 + +该检查在 Fabric 上由 MagicLib 的 `PreLaunchEntrypoint` 触发,在 Forge-Like 平台上则由 `@Mod` 构造器触发,因此都发生在游戏窗口出现之前。它只会执行一次;重复触发会抛出 `IllegalStateException`。 + +MagicLib 在客户端会扫描 `onInitialize` 与 `onInitializeClient`,在服务端则扫描 `onInitialize` 与 `onInitializeServer`。两个方法上找到的声明会被汇总到同一个列表中,因此它们互为备选:任意一个方法通过,该模组即通过。 + +我们不建议在特定环境的入口点方法上使用运行环境(`DistType`)检查——例如在 `onInitializeServer` 上声明 `DistType.CLIENT`,该检查将始终失败。 + +具体扫描哪些类取决于平台: + +- **Fabric-Like**:在 `main`、`client` 与 `server` 键下声明,且实现了 `ModInitializer`、`ClientModInitializer` 或 `DedicatedServerModInitializer` 的入口点类。仅支持纯类名形式——以 `Class::method` 形式声明的入口点会被跳过。 +- **Forge-Like**:标注了 `@Mod` 且实现了 `top.hendrixshen.magiclib.api.entrypoint.ModInitializer`(MagicLib 为跨平台模组提供的 Fabric 风格初始化器)的类。未实现该接口的类完全不会被扫描。 + +只有名称匹配且描述符为 `()V` 的第一个方法会被读取,因此重载的入口点方法只会保留一份声明。 + +#### Mixin 依赖检查 + +MagicLib 提供的 `MagicMixinPlugin` 集成了依赖检查功能:mixin 在运行时被应用时会进行依赖检查,依赖不满足的 mixin 将直接不被应用。注意,mixin 依赖检查不支持方法级与字段级的声明——这在运行时是不安全的。请改为在 mixin 类上声明。 + +`MagicMixinPlugin` 使用的记忆化检查器会按 mixin 类名缓存结果,因此无论一个 mixin 声明了多少个目标类,它都只被求值一次。无法读取到类节点的 mixin 同样不会被应用。 + +mixin 检查失败不会终止游戏。失败原因会交给检查失败回调,而 `MagicMixinPlugin` 仅在 mixin 的 `DEBUG_EXPORT` 环境选项启用时才将其记录为警告。 + +#### 编程式检查 + +`DependencyChecker` 将同样的规则应用于您自己的代码,支持 `ClassNode`、`MethodNode`、`FieldNode` 或反射得到的 `Field`: + +```java +public static void check(Field field, Object obj) { + DependencyCheckResult result = DependencyChecker.check(field, obj); + + if (!result.isSuccess()) { + MagicLib.getLogger().warn("Dependencies not satisfied: {}", result.getReason()); + } +} +``` + +未声明任何依赖的元素会得到一个 `reason` 为 `null` 的成功结果;失败的元素则会得到一棵仅列出实际失败分区的消息树。 + +### 机制概述 + +条件按组组织,每组条件由 `@Dependencies` 包含:组间为或,任意一组通过即可;组内为与,所有条件都必须通过。在同一元素上重复标注 `@Dependencies`,等价于将这些组包裹在 `@CompositeDependencies` 中。 + +未声明任何依赖的元素始终视为满足。 + +组内所有 `require` 条目都必须通过,且所有 `conflict` 条目都不得被触发:任意一个 conflict 条目被命中都会使整组失败。 + +`dependencyType` 决定条件读取哪些属性,以及何时判定通过: + +| 类型 | 读取的属性 | 作为 `require` 时的通过条件 | +|------------------|------------------------------------------|---------------------------------| +| `MOD_ID`(默认) | `value`、`versionPredicates`、`optional` | 模组已加载且其版本满足所有谓词 | +| `DIST` | `distType` | 当前运行环境匹配 | +| `PLATFORM` | `platformType` | 当前加载器平台匹配 | +| `PREDICATE` | `predicate` | 您的谓词对被检查对象返回 `true` | + +`optional` 仅对模组(`MOD_ID`)检查生效:模组缺失时该条目通过;仅当模组已加载时才校验版本谓词,版本不满足仍判定失败。作为 `conflict` 条目时 `optional` 会被忽略:模组已加载且版本匹配才算触发冲突,模组缺失或版本不匹配则不触发。 + +`DistType.ANY` 与 `PlatformType.ANY` 匹配一切,而 `PlatformType.UNKNOWN` 不匹配任何平台。`FABRIC_LIKE` 涵盖 `FABRIC` 与 `QUILT`,`FORGE_LIKE` 涵盖 `FORGE` 与 `NEOFORGE`。 + +谓词条目会对被检查的对象执行您自己的代码,而该对象取决于入口点:mixin 为目标类的 `ClassNode`,配置选项为 `ConfigContainer`,入口点方法则为 `null`。谓词类需要提供无参构造器,每条被解析的声明都会实例化一次,且不能是接口——若谓词可能跨入口点共用,请让它能够容忍参数为 null。 + +完整属性语义见注解 javadoc,此处不再罗列。 + +### 示例 + +#### 仅在模组安装且满足特定版本时应用 Mixin + +下面的 mixin 仅在加载了版本大于等于 20.0.0 的 `modmenu` 时应用。 + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0")) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 仅在未安装模组时应用 Mixin + +下面的 mixin 仅在未加载 `modmenu` 时应用。 + +```java +@Dependencies(conflict = @Dependency("modmenu")) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 模组缺失或满足特定版本时均可应用 Mixin + +下面的 mixin 在未加载 `modmenu`,或加载了版本大于等于 20.0.0 的 `modmenu` 时应用。 + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0", optional = true)) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 将 Mixin 限定在单一加载器平台 + +下面的 mixin 仅在 Fabric 与 Quilt 上应用。 + +```java +@Dependencies(require = @Dependency( + dependencyType = DependencyType.PLATFORM, + platformType = PlatformType.FABRIC_LIKE +)) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 匹配版本区间 + +下面的 mixin 在加载的 `modmenu` 版本大于等于 20.0.0 且小于 21.0.0 时应用。同一字符串内的多个谓词以空格分隔,数组中的每一项都必须匹配。 + +```java +@Dependencies(require = @Dependency( + value = "modmenu", + versionPredicates = {">=20.0.0", "<21.0.0"} +)) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 组合条件(与) + +下面的 mixin 仅在同时加载 `mod_a` 与 `mod_b` 时应用。 + +```java +@Dependencies( + require = { + @Dependency("mod_a"), + @Dependency("mod_b") + } +) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 复合条件(或) + +下面的 mixin 在加载了 `mod_a` 或 `mod_b` 其中之一时应用。 + +```java +@Dependencies(require = @Dependency(value = "mod_a")) +@Dependencies(require = @Dependency(value = "mod_b")) +@Mixin(Screen.class) +public abstract class ScreenMixin { + // 您的代码 +} +``` + +#### 自定义谓词检查 + +让依赖指向一个提供无参构造器的谓词类。对 mixin 而言,被检查的对象是目标类的 `ClassNode`,这正是 `MixinPredicate` 所绑定的类型。 + +```java +public class MyPredicate implements MixinPredicate { + @Override + public boolean test(ClassNode classNode) { + return classNode.superName != null; + } +} +``` + +```java +@Dependencies(require = @Dependency( + dependencyType = DependencyType.PREDICATE, + predicate = MyPredicate.class +)) +public class PredicatedMixin { +} +``` + +#### 入口点依赖检查 + +下面的入口点在缺少 `fabric-api` 时无法启动 Minecraft。在 Forge-Like 平台上,该类还需要实现 `top.hendrixshen.magiclib.api.entrypoint.ModInitializer` 才会被扫描。 + +```java +public class MyModInitializer implements ModInitializer { + @Dependencies(require = @Dependency(value = "fabric-api")) + @Override + public void onInitialize() { + // 您的代码 + } +} +``` + +#### 检查字段上的声明 + +下面的字段自带一份声明,由 `DependencyChecker` 按需求值。第二个参数是谓词将会收到的对象,因此未声明谓词时传 `null` 即可。 + +```java +public class MyFeatures { + @Dependencies(conflict = @Dependency("sodium")) + public static final boolean LEGACY_RENDERER = true; + + public static void init() throws NoSuchFieldException { + Field field = MyFeatures.class.getDeclaredField("LEGACY_RENDERER"); + + if (!DependencyChecker.check(field, null).isSuccess()) { + return; + } + + // 您的代码 + } +} +``` + +### 说明 + +版本谓词由运算符加版本号构成,支持 `>=`、`<=`、`>`、`<`、`=`、`~` 与 `^`,用空格分隔多个条件表示需要同时满足,例如 `>=1.16` 或 `>=1.16 <1.17`。`~` 保留主版本与次版本,`^` 仅保留主版本。 + +末尾的通配符可以替代最后一个版本段——`1.16.x`、`1.16.X` 与 `1.16.*` 均等价于 `~1.16`,`1.x` 等价于 `^1`。通配符必须搭配等号运算符或完全不写运算符,多余的末尾通配符会被丢弃,预发布版本不允许使用通配符。单独一个 `*`,或完全不写谓词,表示匹配所有版本。 + +被检验的版本号必须能解析为语义版本。已加载模组的版本无法解析时,该检查判定失败并记录一条错误日志;谓词中书写的版本号无法解析时,排除两侧边界的运算符(`>` 与 `<`)会被拒绝,其余运算符一律退化为纯字符串相等比较。 + +`@Dependency` 自身不声明任何作用目标,因此只能嵌套在 `@Dependencies` 内部使用。`@Dependencies` 与 `@CompositeDependencies` 都可标注在类型、字段或方法上,但各入口点实际读取哪些位置由该入口点决定。 + +入口点检查会在游戏窗口显示前,对所有已加载模组的入口点类执行,失败即终止启动。mixin 检查则永远不会终止任何事情:它只决定单个 mixin 是否被应用。 diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/DependencyChecker.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/DependencyChecker.java new file mode 100644 index 00000000..21b2e4d1 --- /dev/null +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/DependencyChecker.java @@ -0,0 +1,85 @@ +package top.hendrixshen.magiclib.api.dependency; + +import org.jetbrains.annotations.NotNull; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.MethodNode; + +import top.hendrixshen.magiclib.impl.dependency.DependenciesContainer; +import top.hendrixshen.magiclib.impl.dependency.DependencyCheckResult; +import top.hendrixshen.magiclib.util.DependencyUtil; +import top.hendrixshen.magiclib.util.MiscUtil; +import top.hendrixshen.magiclib.util.collect.InfoNode; + +import java.lang.reflect.Field; +import java.util.List; + +/** + * The runtime facade of the dependency check system. + * + *

+ * It checks the {@code @Dependencies} / {@code @CompositeDependencies} annotations declared on a class, method + * or field at runtime. The annotated element is satisfied when any of its dependency groups passes (logical or + * across the groups, logical and inside a group), which is the same rule used by the mixin and entry point + * checkers. + *

+ */ +public class DependencyChecker { + /** + * Checks the dependencies declared on the given annotated class. + * + * @param annotatedClass The class to check. + * @param obj The object passed to {@code PREDICATE} type dependencies, or {@code null}. + * @param The type of the checked object. + * @return The check result. + */ + public static DependencyCheckResult check(ClassNode annotatedClass, T obj) { + return DependencyChecker.check(DependencyUtil.parseDependencies(annotatedClass, obj)); + } + + /** + * Checks the dependencies declared on the given annotated method. + * + * @param annotatedMethod The method to check. + * @param obj The object passed to {@code PREDICATE} type dependencies, or {@code null}. + * @param The type of the checked object. + * @return The check result. + */ + public static DependencyCheckResult check(MethodNode annotatedMethod, T obj) { + return DependencyChecker.check(DependencyUtil.parseDependencies(annotatedMethod, obj)); + } + + /** + * Checks the dependencies declared on the given annotated field. + * + * @param annotatedField The field to check. + * @param obj The object passed to {@code PREDICATE} type dependencies, or {@code null}. + * @param The type of the checked object. + * @return The check result. + */ + public static DependencyCheckResult check(FieldNode annotatedField, T obj) { + return DependencyChecker.check(DependencyUtil.parseDependencies(annotatedField, obj)); + } + + /** + * Checks the dependencies declared on the given annotated field. + * + * @param annotatedField The field to check. + * @param obj The object passed to {@code PREDICATE} type dependencies, or {@code null}. + * @param The type of the checked object. + * @return The check result. + */ + public static DependencyCheckResult check(Field annotatedField, T obj) { + return DependencyChecker.check(DependencyUtil.parseDependencies(annotatedField, obj)); + } + + private static DependencyCheckResult check(@NotNull List> dependencies) { + if (dependencies.isEmpty() || dependencies.stream().anyMatch(DependenciesContainer::isSatisfied)) { + return new DependencyCheckResult(true, null); + } + + InfoNode rootNode = new InfoNode(null, ""); + MiscUtil.generateDependencyCheckMessage(dependencies, rootNode); + return new DependencyCheckResult(false, rootNode.toString()); + } +} diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/CompositeDependencies.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/CompositeDependencies.java index a31b0f23..d61575f4 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/CompositeDependencies.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/CompositeDependencies.java @@ -6,15 +6,26 @@ import java.lang.annotation.Target; /** - * CompositeDependencies annotation. + * The container annotation that bundles multiple {@link Dependencies} declared on the same element. + * + *

+ * {@link Dependencies} is repeatable, so repeating it on an element is equivalent to declaring a + * {@code @CompositeDependencies}. Every {@link Dependencies} in the value is an alternative: the annotated + * element is satisfied when any of the groups passes (logical or). + *

*/ @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface CompositeDependencies { /** - * The dependencies located in this list are logical and. + * The dependency groups declared on the element. * - * @return Dependencies list. + *

+ * Each group is satisfied when all of its required dependencies pass and none of its conflict + * dependencies is triggered; the element is satisfied when any group passes. + *

+ * + * @return The dependency groups. */ Dependencies[] value() default {}; } diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependencies.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependencies.java index 162562a4..a691918d 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependencies.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependencies.java @@ -7,31 +7,38 @@ import java.lang.annotation.Target; /** - * Dependencies annotation. + * Declares one dependency group on an annotated element. + * + *

+ * A single group is satisfied when every entry in {@link Dependencies#require()} passes and every entry in + * {@link Dependencies#conflict()} is not triggered. Multiple groups on the same element, either by repeating + * {@code @Dependencies} or by using {@link CompositeDependencies}, are alternatives (logical or): the element + * is satisfied when any group passes. + *

*/ @Repeatable(CompositeDependencies.class) @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Dependencies { /** - * All conditions satisfied, test passed. + * The required dependencies of this group. * *

- * The dependencies located in this list are logical or. + * All of them must be satisfied for the group to pass (logical and). *

* - * @return True if all conditions are satisfied, otherwise false. + * @return The required dependencies. */ Dependency[] require() default {}; /** - * Any conditions satisfied, test fails. + * The conflict dependencies of this group. * *

- * The dependencies located in this list are logical or. + * The group fails when any of them is satisfied, so none of them may be triggered for the group to pass. *

* - * @return True if none of the conditions are satisfied, otherwise false. + * @return The conflict dependencies. */ Dependency[] conflict() default {}; } diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependency.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependency.java index 12288589..5cbbe44e 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependency.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/api/dependency/annotation/Dependency.java @@ -68,7 +68,7 @@ * The value is used if {@link Dependency#dependencyType()} == {@link DependencyType#MOD_ID} * *

- * The condition is satisfied when the testing version matches any versionPredicate, or no + * The condition is satisfied when the testing version matches every versionPredicate, or no * versionPredicate is given. *

*/ diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/DependencyContainer.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/DependencyContainer.java index 2630df02..2465e609 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/DependencyContainer.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/DependencyContainer.java @@ -49,25 +49,29 @@ private DependencyContainer(String value, DependencyType dependencyType, DistTyp } @SuppressWarnings("unchecked") + private static @NotNull SimplePredicate instantiatePredicate(@NotNull String predicateClassName) { + try { + Class clazz = Class.forName(predicateClassName); + + if (clazz.isInterface()) { + throw new IllegalStateException(String.format("Predicate class %s is a interface, excepted implementation class.", + clazz.getName())); + } + + return (SimplePredicate) clazz.getConstructor().newInstance(); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(String.format("Failed to instantiate a Predicate from class %s", + predicateClassName), e); + } + } + public static @NotNull DependencyContainer of(@NotNull Dependency dependency, T obj) { SimplePredicate predicate = null; if (dependency.dependencyType() == DependencyType.PREDICATE) { - try { - Class clazz = Class.forName(dependency.predicate().getName()); - - if (clazz.isInterface()) { - throw new IllegalStateException(String.format("Predicate class %s is a interface, excepted implementation class.", - clazz.getName())); - } else { - predicate = (SimplePredicate) clazz.getConstructor().newInstance(); - } - } catch (IllegalStateException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException(String.format("Failed to instantiate a Predicate from class %s: %s", - dependency.predicate().getName(), e)); - } + predicate = DependencyContainer.instantiatePredicate(dependency.predicate().getName()); } // TODO: Remove this in the future @@ -85,7 +89,6 @@ private DependencyContainer(String value, DependencyType dependencyType, DistTyp predicate, dependency.optional(), obj); } - @SuppressWarnings("unchecked") public static @NotNull DependencyContainer of(AnnotationNode annotationNode, T obj) { SimplePredicate predicate = null; DependencyType dependencyType = Annotations.getValue(annotationNode, "dependencyType", @@ -95,22 +98,7 @@ private DependencyContainer(String value, DependencyType dependencyType, DistTyp Type type = Annotations.getValue(annotationNode, "predicate"); Objects.requireNonNull(type, "Dependency type is set to PREDICATE mode, which requires the predicate field to be specified!"); - - try { - Class clazz = Class.forName(type.getClassName()); - - if (clazz.isInterface()) { - throw new IllegalStateException(String.format("Predicate class %s is a interface, excepted implementation class.", - clazz.getName())); - } else { - predicate = (SimplePredicate) clazz.getConstructor().newInstance(); - } - } catch (IllegalStateException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException(String.format("Failed to instantiate a Predicate from class %s", - type.getClassName()), e); - } + predicate = DependencyContainer.instantiatePredicate(type.getClassName()); } return new DependencyContainer<>( diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/EntryPointDependency.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/EntryPointDependency.java index 6bab2c05..848159e4 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/EntryPointDependency.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/dependency/EntryPointDependency.java @@ -6,7 +6,6 @@ import lombok.NoArgsConstructor; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.MethodNode; @@ -14,6 +13,7 @@ import top.hendrixshen.magiclib.api.dependency.DependencyCheckException; import top.hendrixshen.magiclib.api.i18n.I18n; import top.hendrixshen.magiclib.api.platform.DistType; +import top.hendrixshen.magiclib.api.platform.Platform; import top.hendrixshen.magiclib.api.platform.adapter.ModContainerAdapter; import top.hendrixshen.magiclib.api.platform.adapter.ModMetaDataAdapter; import top.hendrixshen.magiclib.impl.gui.fabric.FabricGuiEntry; @@ -23,7 +23,6 @@ import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; @ApiStatus.Internal @@ -39,45 +38,51 @@ public void check() { throw new IllegalStateException("Re-trigger EntryPointDependency check."); } + Platform platform = MagicLib.getInstance().getCurrentPlatform(); + DistType currentDistType = platform.getCurrentDistType(); List exceptions = Lists.newArrayList(); - for (ModContainerAdapter mod : MagicLib.getInstance().getCurrentPlatform().getMods()) { + for (ModContainerAdapter mod : platform.getMods()) { for (ClassNode entryPoint : mod.getModEntryPoint().getMagicEntryPoints()) { - exceptions.add(this.check(mod.getModMetaData(), entryPoint)); + this.checkEntryPoint(currentDistType, mod.getModMetaData(), entryPoint, exceptions); } } - exceptions.stream() - .filter(Objects::nonNull) - .reduce((a, b) -> new DependencyCheckException(a.getMessage() + b.getMessage())) - .ifPresent(e -> { - e.setStackTrace(new StackTraceElement[0]); - FabricGuiEntry.displayCriticalError(e, true); - }); + if (!exceptions.isEmpty()) { + StringBuilder message = new StringBuilder(); + + for (DependencyCheckException exception : exceptions) { + message.append(exception.getMessage()); + } + + DependencyCheckException exception = new DependencyCheckException(message.toString()); + exception.setStackTrace(new StackTraceElement[0]); + FabricGuiEntry.displayCriticalError(exception, true); + } + this.isChecked.set(true); } - private @Nullable DependencyCheckException check(ModMetaDataAdapter modMetaDataAdapter, ClassNode entryPoint) { + private void checkEntryPoint(DistType currentDistType, ModMetaDataAdapter modMetaData, ClassNode entryPoint, + List exceptions) { List> dependencies = Lists.newArrayList(); - if (MagicLib.getInstance().getCurrentPlatform().getCurrentDistType() - .matches(DistType.CLIENT)) { + if (currentDistType.matches(DistType.CLIENT)) { dependencies.addAll(this.getDependencies("onInitializeClient", entryPoint)); - } else if (MagicLib.getInstance().getCurrentPlatform().getCurrentDistType() - .matches(DistType.SERVER)) { + } else if (currentDistType.matches(DistType.SERVER)) { dependencies.addAll(this.getDependencies("onInitializeServer", entryPoint)); } dependencies.addAll(this.getDependencies("onInitialize", entryPoint)); if (dependencies.isEmpty() || dependencies.stream().anyMatch(DependenciesContainer::isSatisfied)) { - return null; + return; } InfoNode rootNode = new InfoNode(null, I18n.tr("magiclib.dependency.checker.entrypoint.title", - modMetaDataAdapter.getName(), modMetaDataAdapter.getModId(), modMetaDataAdapter.getVersion())); + modMetaData.getName(), modMetaData.getModId(), modMetaData.getVersion())); MiscUtil.generateDependencyCheckMessage(dependencies, rootNode); - return new DependencyCheckException("\n" + rootNode); + exceptions.add(new DependencyCheckException("\n" + rootNode)); } private @NotNull List> getDependencies(String name, @NotNull ClassNode entryPoint) { diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/MemorizedMixinChecker.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/MemorizedMixinChecker.java index 154dc2b8..c8347cae 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/MemorizedMixinChecker.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/MemorizedMixinChecker.java @@ -21,6 +21,10 @@ package top.hendrixshen.magiclib.impl.mixin.checker; import com.google.common.collect.Maps; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; import top.hendrixshen.magiclib.api.mixin.checker.MixinDependencyCheckFailureCallback; import top.hendrixshen.magiclib.api.mixin.checker.MixinDependencyChecker; @@ -29,10 +33,17 @@ /** * Reference to conditional mixin. + * + *

+ * A mixin may target multiple classes, and the check result of a mixin depends on both the mixin class and + * its target class (the target class node is passed to the predicate of {@code PREDICATE} type dependencies). + * Therefore the memory is keyed by the {@code (targetClassName, mixinClassName)} pair instead of the mixin + * class name alone, otherwise the result of the first target would be wrongly reused for the other targets. + *

*/ public class MemorizedMixinChecker implements MixinDependencyChecker { private final MixinDependencyChecker checker; - private final Map memory = Maps.newConcurrentMap(); + private final Map memory = Maps.newConcurrentMap(); public MemorizedMixinChecker(MixinDependencyChecker checker) { this.checker = checker; @@ -40,18 +51,21 @@ public MemorizedMixinChecker(MixinDependencyChecker checker) { @Override public boolean check(String targetClassName, String mixinClassName) { - Boolean result = this.memory.get(mixinClassName); - - if (result == null) { - result = this.checker.check(targetClassName, mixinClassName); - this.memory.put(mixinClassName, result); - } - - return result; + CheckKey key = new CheckKey(targetClassName, mixinClassName); + return this.memory.computeIfAbsent(key, + k -> this.checker.check(k.getTargetClassName(), k.getMixinClassName())); } @Override public void setCheckFailureCallback(MixinDependencyCheckFailureCallback callback) { this.checker.setCheckFailureCallback(callback); } + + @AllArgsConstructor(access = AccessLevel.PRIVATE) + @EqualsAndHashCode + @Getter + private static final class CheckKey { + private final String targetClassName; + private final String mixinClassName; + } } diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/SimpleMixinChecker.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/SimpleMixinChecker.java index 5a1bc5e5..43cb8f88 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/SimpleMixinChecker.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/impl/mixin/checker/SimpleMixinChecker.java @@ -49,20 +49,21 @@ public boolean check(String targetClassName, String mixinClassName) { return false; } - List> nodes = DependencyUtil.parseDependencies(mixinClassNode, targetClassNode); + List> dependencies = DependencyUtil.parseDependencies( + mixinClassNode, targetClassNode); - if (nodes.isEmpty()) { - return true; - } - - if (nodes.stream().anyMatch(DependenciesContainer::isSatisfied)) { + if (dependencies.isEmpty() || dependencies.stream().anyMatch(DependenciesContainer::isSatisfied)) { return true; } InfoNode rootNode = new InfoNode(null, I18n.tr("magiclib.dependency.checker.mixin.title", mixinClassName, targetClassName)); - MiscUtil.generateDependencyCheckMessage(nodes, rootNode); - this.onCheckFailure(targetClassName, mixinClassName, new DependencyCheckException(rootNode.toString())); + MiscUtil.generateDependencyCheckMessage(dependencies, rootNode); + + if (this.failureCallback != null) { + this.failureCallback.callback(targetClassName, mixinClassName, + new DependencyCheckException(rootNode.toString())); + } return false; } @@ -71,10 +72,4 @@ public boolean check(String targetClassName, String mixinClassName) { public void setCheckFailureCallback(MixinDependencyCheckFailureCallback callback) { this.failureCallback = callback; } - - private void onCheckFailure(String targetClassName, String mixinClassName, DependencyCheckException result) { - if (this.failureCallback != null) { - this.failureCallback.callback(targetClassName, mixinClassName, result); - } - } } diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/DependencyUtil.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/DependencyUtil.java index 171ce080..3ebb65c4 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/DependencyUtil.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/DependencyUtil.java @@ -22,39 +22,37 @@ public class DependencyUtil { public static List> parseDependencies(ClassNode classNode, T instance) { - List> composite = DependencyUtil.convertCompositeDependencies(ValueContainer - .ofNullable(Annotations.getVisible(classNode, CompositeDependencies.class)), instance); - - if (!composite.isEmpty()) { - return composite; - } - - return DependencyUtil.convertDependencies(ValueContainer.ofNullable(Annotations.getVisible(classNode, - Dependencies.class)), instance); + return DependencyUtil.parseDependencies( + ValueContainer.ofNullable(Annotations.getVisible(classNode, CompositeDependencies.class)), + ValueContainer.ofNullable(Annotations.getVisible(classNode, Dependencies.class)), + instance); } public static @NotNull List> parseDependencies(MethodNode methodNode, T instance) { - List> composite = DependencyUtil.convertCompositeDependencies(ValueContainer - .ofNullable(Annotations.getVisible(methodNode, CompositeDependencies.class)), instance); - - if (!composite.isEmpty()) { - return composite; - } - - return DependencyUtil.convertDependencies(ValueContainer.ofNullable(Annotations.getVisible(methodNode, - Dependencies.class)), instance); + return DependencyUtil.parseDependencies( + ValueContainer.ofNullable(Annotations.getVisible(methodNode, CompositeDependencies.class)), + ValueContainer.ofNullable(Annotations.getVisible(methodNode, Dependencies.class)), + instance); } public static @NotNull List> parseDependencies(FieldNode fieldNode, T instance) { - List> composite = DependencyUtil.convertCompositeDependencies(ValueContainer - .ofNullable(Annotations.getVisible(fieldNode, CompositeDependencies.class)), instance); + return DependencyUtil.parseDependencies( + ValueContainer.ofNullable(Annotations.getVisible(fieldNode, CompositeDependencies.class)), + ValueContainer.ofNullable(Annotations.getVisible(fieldNode, Dependencies.class)), + instance); + } - if (!composite.isEmpty()) { - return composite; + private static List> parseDependencies( + @NotNull ValueContainer composite, + @NotNull ValueContainer dependencies, + T instance) { + List> compositeList = DependencyUtil.convertCompositeDependencies(composite, instance); + + if (!compositeList.isEmpty()) { + return compositeList; } - return DependencyUtil.convertDependencies(ValueContainer.ofNullable(Annotations.getVisible(fieldNode, - Dependencies.class)), instance); + return DependencyUtil.convertDependencies(dependencies, instance); } private static List> convertCompositeDependencies( diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/MiscUtil.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/MiscUtil.java index faa32920..58940317 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/MiscUtil.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/MiscUtil.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Locale; import java.util.function.BiConsumer; +import java.util.function.Function; public class MiscUtil { public static @NotNull String getSystemLanguageCode() { @@ -29,19 +30,61 @@ public static T cast(Object obj) { public static void generateDependencyCheckMessage(@NotNull List> dependencies, InfoNode rootNode) { - boolean first = true; + MiscUtil.generateDependencyCheckMessage(dependencies, rootNode, false, + text -> text, result -> result.getReason()); + } + + /** + * Builds the dependency check failure message tree. + * + *

+ * Every {@link DependenciesContainer} in the list is an alternative (OR), while the require and conflict + * dependencies inside one container must all pass (AND). Each container is only evaluated once, and its + * check results are rendered as a tree of {@code composite / or / require / conflict} nodes under the + * given root node. + *

+ * + *

+ * A section is only rendered when the container declares the corresponding dependencies + * ({@code showSatisfiedDependencies == true}, e.g. the config GUI) or when at least one of its checks + * failed ({@code showSatisfiedDependencies == false}, e.g. failure messages). + *

+ * + * @param dependencies The dependency containers to render. + * @param rootNode The root node of the message tree. + * @param showSatisfiedDependencies Whether to render sections whose checks all passed. + * @param labelDecorator Decorates section labels, e.g. with GUI color codes. + * @param resultDecorator Decorates each check result line. + * @param The type of the checked object. + */ + public static void generateDependencyCheckMessage(@NotNull List> dependencies, + InfoNode rootNode, + boolean showSatisfiedDependencies, + @NotNull Function labelDecorator, + @NotNull Function resultDecorator) { + boolean firstRendered = true; boolean composite = false; - InfoNode compositeNode = new InfoNode(null, I18n.tr("magiclib.dependency.label.composite")); + InfoNode compositeNode; + + for (DependenciesContainer container : dependencies) { + List conflictResults = container.checkConflict(); + List requireResults = container.checkRequire(); + boolean renderConflict = MiscUtil.shouldRender(showSatisfiedDependencies, conflictResults); + boolean renderRequire = MiscUtil.shouldRender(showSatisfiedDependencies, requireResults); + + if (!renderConflict && !renderRequire) { + continue; + } - for (DependenciesContainer dependenciesContainer : dependencies) { - boolean conflictSatisfied = dependenciesContainer.isConflictSatisfied(); - boolean requireSatisfied = dependenciesContainer.isRequireSatisfied(); InfoNode orNode = null; - if (first) { - first = false; - } else if (!conflictSatisfied || !requireSatisfied) { + if (firstRendered) { + firstRendered = false; + } else { if (!composite) { + compositeNode = new InfoNode(null, + labelDecorator.apply(I18n.tr("magiclib.dependency.label.composite"))); + for (InfoNode child : rootNode.getChildren()) { child.moveTo(compositeNode); } @@ -50,29 +93,44 @@ public static void generateDependencyCheckMessage(@NotNull List results) { + if (results.isEmpty()) { + return false; + } + + if (showSatisfiedDependencies) { + return true; + } + + return results.stream().anyMatch(result -> !result.isSuccess()); + } + @Deprecated @ApiStatus.ScheduledForRemoval() public static Gson GSON = GsonUtil.GSON; diff --git a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/collect/InfoNode.java b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/collect/InfoNode.java index 53cf260c..12052db9 100644 --- a/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/collect/InfoNode.java +++ b/magiclib-core/common/src/main/java/top/hendrixshen/magiclib/util/collect/InfoNode.java @@ -5,11 +5,21 @@ import lombok.Getter; import lombok.Setter; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; +/** + * A node of a tree used to render nested text messages, e.g. the dependency check failure tree. + * + *

+ * A node carries a text name and an ordered list of children. Creating a node with a non-null parent + * automatically attaches it to that parent, and {@link InfoNode#moveTo(InfoNode)} re-attaches a node to + * another parent. The tree is rendered by {@link InfoNode#toString()} as one line per node, indented by one + * tab per depth level. + *

+ */ public class InfoNode { @Getter @Setter @@ -18,18 +28,37 @@ public class InfoNode { private InfoNode parent; private final List children = Lists.newArrayList(); - public InfoNode(InfoNode parent, String name) { - Objects.requireNonNull(name, "Null name"); + /** + * Creates a node and attaches it to the given parent. + * + *

+ * When {@code parent} is not null, this node is appended to the parent's children immediately. The name + * must not be null; an empty name renders no visible text on its own line. + *

+ * + * @param parent The parent node, or {@code null} to create a root node. + * @param name The text of this node. + */ + public InfoNode(@Nullable InfoNode parent, String name) { + this.name = Objects.requireNonNull(name, "name"); + this.parent = parent; if (parent != null) { parent.addChild(this); } - - this.parent = parent; - this.name = name; } - public void moveTo(InfoNode newParent) { + /** + * Re-attaches this node to the given parent. + * + *

+ * This node is removed from its current parent's children, if any, and then appended to the new + * parent's children. Passing {@code null} detaches this node into a root. + *

+ * + * @param newParent The new parent node, or {@code null} to detach this node. + */ + public void moveTo(@Nullable InfoNode newParent) { if (this.parent != null) { this.parent.children.remove(this); } @@ -41,22 +70,43 @@ public void moveTo(InfoNode newParent) { } } + /** + * Gets a snapshot of this node's children. + * + *

+ * The returned list is a defensive copy rather than a live view, so the tree can be modified safely + * while iterating the snapshot. This is required by the re-attaching flow that moves the root children + * under a composite node. + *

+ * + * @return The children of this node. + */ + public @NotNull ImmutableList getChildren() { + return ImmutableList.copyOf(this.children); + } + private void addChild(InfoNode infoNode) { this.children.add(infoNode); } - private @NotNull String getString(String prefix) { - return prefix + this.name + "\n" + this.children.stream() - .map(n -> n.getString(prefix + "\t")) - .collect(Collectors.joining()); - } + private void appendTo(@NotNull StringBuilder builder, @NotNull String prefix) { + builder.append(prefix).append(this.name).append('\n'); + String childPrefix = prefix + "\t"; - public @NotNull ImmutableList getChildren() { - return ImmutableList.copyOf(this.children); + for (InfoNode child : this.children) { + child.appendTo(builder, childPrefix); + } } @Override public @NotNull String toString() { - return this.getString("").replaceAll("\n*$", ""); + StringBuilder builder = new StringBuilder(); + this.appendTo(builder, ""); + + while (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') { + builder.setLength(builder.length() - 1); + } + + return builder.toString(); } } diff --git a/magiclib-malilib-extra/README.md b/magiclib-malilib-extra/README.md new file mode 100644 index 00000000..0a4b8376 --- /dev/null +++ b/magiclib-malilib-extra/README.md @@ -0,0 +1,128 @@ +# MagicLib Malilib Extra + +Malilib extension of MagicLib, providing enhanced config options: dependency checking, comment processing and usage statistics, plus the MagicLib config GUI. + +## Features + +- [Config Option Dependency](#config-option-dependency) + +## Config Option Dependency + +### Background + +The config container and base interfaces of this feature reference [TweakerMore](https://github.com/Fallen-Breath/tweakermore) by [Fallen-Breath](https://github.com/Fallen-Breath), and reuse the dependency check annotations of MagicLib Core to bring dependency conditions to config options. + +### What it does + +Makes the [Dependency Check](../magiclib-core/README.md#dependency-check) feature work together with config options: an option declares the conditions under which it is available, and the config GUI reacts to them. + +The check drives the GUI presentation only. It never rewrites a stored value, and it does not stop your own code from reading an option or triggering its hotkey — see [Notes](#notes). + +#### Option visibility + +`MagicConfigGui` filters an option out of the list when its dependencies are unsatisfied and `hideUnAvailableConfigs()` returns `true`. That method returns `false` in the base class, so hiding is opt-in for your GUI; the MagicLib config GUI overrides it with the `hideUnavailableConfigs` option, which defaults to `true`. + +A category navigation button is dropped as well when no option of that category survives the dependency and debug/dev filters, so a fully unavailable category does not show up as an empty tab. + +Options that stay visible are still marked: inside a `MagicConfigGui`, their label lines are rendered in dark red, which takes precedence over the blue of `debugOnly` options and the light purple of `devOnly` options. + +#### Comment hints + +When not every dependency group passes, a `Dependencies:` footer built from the check results is appended to the option comment. It is rendered as a tree of `Composite dependency` / `Or` / `Require` / `Conflict` sections with one line per condition, colored green when the condition passed and red when it failed. + +Unlike a failure popup, the footer also lists the conditions that passed, so the whole declaration stays readable. It is appended as soon as one group fails, which can happen while the option itself is still available — a single passing group is enough for that. + +The footer is injected through a mixin on the malilib `ConfigBase#getComment`, so it reaches every GUI that reads the option comment; call `getCommentNoFooter()` when you need the comment without it. + +#### Config values + +Reading the value of an option always returns the value that is actually stored, whether or not its dependencies are satisfied. Loading and saving do not filter by dependency state either, so an unavailable option keeps its value across restarts instead of falling back to the default. + +### How it works + +Put `@Config` and `@Dependencies` on the same static option field; `MagicConfigManager#parseConfigClass` wraps the field into a `ConfigContainer` and parses the dependency declarations on it at that point. + +The object checked by the dependencies is the `ConfigContainer` itself, so a `PREDICATE` condition implements `SimplePredicate` and can reach the option (`getConfig()`), its name, its category and its config manager. + +Conditions are organized in groups, each group being wrapped by `@Dependencies`: the groups are alternatives (or) — any passing group is enough — while inside a group every `require` condition must pass and no `conflict` condition may be triggered (and). An option without any dependency declaration is always satisfied. + +A condition can check mod presence, mod version, runtime distribution or loader platform; `optional` only affects the mod (`MOD_ID`) checks. + +Nothing is cached: `ConfigContainer#isSatisfied()` re-evaluates the conditions on every call, so a GUI redraw always reflects the current environment. + +### Examples + +#### Show an option only when a matching mod version is installed + +The option below is available only when `modmenu` 20.0.0 or newer is loaded. + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0")) +@Config(category = "generic") +public static final MagicConfigBoolean screenRelated = Configs.cf.newConfigBoolean("screenRelated", false); +``` + +#### Hide an option when a conflicting mod is installed + +The option below is available only when `sodium` is not loaded. + +```java +@Dependencies(conflict = @Dependency("sodium")) +@Config(category = "generic") +public static final MagicConfigBoolean legacyRenderer = Configs.cf.newConfigBoolean("legacyRenderer", false); +``` + +#### Show an option when a mod is missing or a matching version is installed + +The option below is available when `modmenu` is missing, or when a version 20.0.0 or newer is loaded. + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0", optional = true)) +@Config(category = "generic") +public static final MagicConfigBoolean screenRelated = Configs.cf.newConfigBoolean("screenRelated", false); +``` + +#### Composite conditions (or) + +The option below is available when either `mod_a` or `mod_b` is loaded. Repeating `@Dependencies` is equivalent to wrapping the groups in `@CompositeDependencies`. + +```java +@Dependencies(require = @Dependency("mod_a")) +@Dependencies(require = @Dependency("mod_b")) +@Config(category = "generic") +public static final MagicConfigBoolean eitherMod = Configs.cf.newConfigBoolean("eitherMod", false); +``` + +#### Custom predicate check + +Point the dependency at a predicate class that provides a no-argument constructor; it receives the config container of the option. + +```java +public class MyPredicate implements SimplePredicate { + @Override + public boolean test(ConfigContainer container) { + return container.getConfigManager() != null; + } +} +``` + +```java +@Dependencies(require = @Dependency( + dependencyType = DependencyType.PREDICATE, + predicate = MyPredicate.class +)) +@Config(category = "generic") +public static final MagicConfigBoolean predicated = Configs.cf.newConfigBoolean("predicated", false); +``` + +See [Dependency Check](../magiclib-core/README.md#examples) for more examples of the condition types shared with the other entry points. + +### Notes + +Options are created through the `MagicConfigFactory` of your config manager; `Configs.cf` in the examples is that factory. + +The check is a GUI concern only: the hotkeys of unsatisfied options stay registered, and their values can still be read and written. Guard the behavior yourself with `ConfigContainer#isSatisfied()` when an option must not take effect. + +The bottom line of `MagicConfigGui` counts an option as available only when it passes the dependency check and the debug/dev filters; everything else is reported as unavailable in its hover text. + +The full semantics of the dependency check and the version predicate syntax are in the Dependency Check feature of MagicLib Core. diff --git a/magiclib-malilib-extra/README_ZH_CN.md b/magiclib-malilib-extra/README_ZH_CN.md new file mode 100644 index 00000000..17eff778 --- /dev/null +++ b/magiclib-malilib-extra/README_ZH_CN.md @@ -0,0 +1,128 @@ +# MagicLib Malilib Extra + +MagicLib 的 Malilib 扩展,提供增强的配置选项:依赖检查、注释处理与使用统计,以及 MagicLib 配置 GUI。 + +## 功能 + +- [配置选项依赖](#配置选项依赖) + +## 配置选项依赖 + +### 背景 + +该功能的配置容器与基础接口参考 [Fallen-Breath](https://github.com/Fallen-Breath) 的 [TweakerMore](https://github.com/Fallen-Breath/tweakermore),并复用 MagicLib Core 的依赖检查注解,将依赖条件引入配置选项。 + +### 功能简介 + +使 [依赖检查](../magiclib-core/README_ZH_CN.md#依赖检查) 功能能够与配置选项一起工作:由选项声明它在何种条件下可用,配置 GUI 再据此作出响应。 + +检查只影响 GUI 的呈现。它既不会改写已存储的值,也不会阻止您自己的代码读取选项或触发其热键——详见 [说明](#说明)。 + +#### 选项可见性 + +当选项的依赖条件不满足且 `hideUnAvailableConfigs()` 返回 `true` 时,`MagicConfigGui` 会将该选项从列表中过滤掉。该方法在基类中返回 `false`,因此隐藏行为对您的 GUI 而言是可选择的;MagicLib 配置 GUI 用 `hideUnavailableConfigs` 选项覆写了它,其默认值为 `true`。 + +当某个分类下没有任何选项能通过依赖与调试/开发环境过滤时,该分类的导航按钮也会一并移除,因此完全不可用的分类不会显示为空标签页。 + +仍然可见的选项也会被标记:在 `MagicConfigGui` 中,其标签文本行以深红色渲染,该优先级高于 `debugOnly` 选项的蓝色与 `devOnly` 选项的浅紫色。 + +#### 注释提示 + +当并非所有依赖组都通过时,选项注释末尾会附加一段由检查结果构建的 `Dependencies:`(中文界面为「依赖:」)页脚。它以 `复合依赖` / `或` / `必要` / `冲突` 分区构成的树形呈现,每个条件占一行,条件通过为绿色,失败为红色。 + +与失败弹窗不同,页脚也会列出已通过的条件,因此整份声明都保持可读。只要有任意一组失败就会附加页脚,而选项本身仍可能是可用的——一组通过即已足够。 + +页脚通过对 malilib `ConfigBase#getComment` 的 mixin 注入,因此所有读取选项注释的 GUI 都能获得它;若您需要不含页脚的注释,请调用 `getCommentNoFooter()`。 + +#### 配置值 + +读取选项的值时,无论其依赖条件是否满足,返回的都是实际存储的值。加载与保存同样不会按依赖状态过滤,因此不可用的选项在重启后仍会保留其值,而不会回退为默认值。 + +### 机制概述 + +将 `@Config` 与 `@Dependencies` 标注在同一个静态选项字段上;`MagicConfigManager#parseConfigClass` 会把该字段包装为 `ConfigContainer`,并在此时解析其上的依赖声明。 + +依赖检查的对象是 `ConfigContainer` 本身,因此 `PREDICATE` 条件实现 `SimplePredicate`,可访问该选项(`getConfig()`)、其名称、所属分类与配置管理器。 + +条件按组组织,每组条件由 `@Dependencies` 包含:组间为或,任意一组通过即可;组内为与,所有 `require` 条件都必须通过且所有 `conflict` 条件都不得被触发。未声明任何依赖的选项始终视为满足。 + +条件可检查模组是否存在、模组版本、运行环境或加载器平台;`optional` 仅对模组(`MOD_ID`)检查生效。 + +检查结果不会被缓存:`ConfigContainer#isSatisfied()` 在每次调用时重新求值,因此 GUI 重绘总是反映当前环境。 + +### 示例 + +#### 仅在模组安装且满足特定版本时显示选项 + +下面的选项仅在加载了版本大于等于 20.0.0 的 `modmenu` 时可用。 + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0")) +@Config(category = "generic") +public static final MagicConfigBoolean screenRelated = Configs.cf.newConfigBoolean("screenRelated", false); +``` + +#### 在存在冲突模组时隐藏选项 + +下面的选项仅在未加载 `sodium` 时可用。 + +```java +@Dependencies(conflict = @Dependency("sodium")) +@Config(category = "generic") +public static final MagicConfigBoolean legacyRenderer = Configs.cf.newConfigBoolean("legacyRenderer", false); +``` + +#### 模组缺失或满足特定版本时均显示选项 + +下面的选项在未加载 `modmenu`,或加载了版本大于等于 20.0.0 的 `modmenu` 时可用。 + +```java +@Dependencies(require = @Dependency(value = "modmenu", versionPredicates = ">=20.0.0", optional = true)) +@Config(category = "generic") +public static final MagicConfigBoolean screenRelated = Configs.cf.newConfigBoolean("screenRelated", false); +``` + +#### 复合条件(或) + +下面的选项在加载了 `mod_a` 或 `mod_b` 其中之一时可用。重复标注 `@Dependencies` 等价于将这些组包裹在 `@CompositeDependencies` 中。 + +```java +@Dependencies(require = @Dependency("mod_a")) +@Dependencies(require = @Dependency("mod_b")) +@Config(category = "generic") +public static final MagicConfigBoolean eitherMod = Configs.cf.newConfigBoolean("eitherMod", false); +``` + +#### 自定义谓词检查 + +让依赖指向一个提供无参构造器的谓词类;该谓词会收到选项的配置容器。 + +```java +public class MyPredicate implements SimplePredicate { + @Override + public boolean test(ConfigContainer container) { + return container.getConfigManager() != null; + } +} +``` + +```java +@Dependencies(require = @Dependency( + dependencyType = DependencyType.PREDICATE, + predicate = MyPredicate.class +)) +@Config(category = "generic") +public static final MagicConfigBoolean predicated = Configs.cf.newConfigBoolean("predicated", false); +``` + +更多与其他入口点共用的条件类型示例,请参考 [依赖检查](../magiclib-core/README_ZH_CN.md#示例)。 + +### 说明 + +选项通过您的配置管理器的 `MagicConfigFactory` 创建,示例中的 `Configs.cf` 即该工厂。 + +检查仅关乎 GUI:依赖不满足的选项,其热键仍处于注册状态,其值仍可读写。当某选项不应生效时,请自行用 `ConfigContainer#isSatisfied()` 加以保护。 + +`MagicConfigGui` 的底部统计行仅在选项通过依赖检查与调试/开发环境过滤时将其计为可用;其余选项都会在其悬停文本中被报告为不可用。 + +依赖检查的完整语义与版本谓词语法见 MagicLib Core 的依赖检查功能。 diff --git a/magiclib-malilib-extra/src/main/java/top/hendrixshen/magiclib/impl/malilib/config/ConfigContainer.java b/magiclib-malilib-extra/src/main/java/top/hendrixshen/magiclib/impl/malilib/config/ConfigContainer.java index 2af4825b..6b79ac58 100644 --- a/magiclib-malilib-extra/src/main/java/top/hendrixshen/magiclib/impl/malilib/config/ConfigContainer.java +++ b/magiclib-malilib-extra/src/main/java/top/hendrixshen/magiclib/impl/malilib/config/ConfigContainer.java @@ -26,6 +26,7 @@ import lombok.Getter; import lombok.Setter; import lombok.SneakyThrows; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -36,11 +37,11 @@ import top.hendrixshen.magiclib.api.malilib.config.option.MagicIConfigBase; import top.hendrixshen.magiclib.game.malilib.Configs; import top.hendrixshen.magiclib.impl.dependency.DependenciesContainer; -import top.hendrixshen.magiclib.impl.dependency.DependencyCheckResult; import top.hendrixshen.magiclib.impl.malilib.config.comment.MarkProcessor; import top.hendrixshen.magiclib.impl.malilib.config.comment.TagProcessor; import top.hendrixshen.magiclib.impl.malilib.config.statistic.ConfigStatistic; import top.hendrixshen.magiclib.util.DependencyUtil; +import top.hendrixshen.magiclib.util.MiscUtil; import top.hendrixshen.magiclib.util.collect.InfoNode; import java.lang.reflect.Field; @@ -145,6 +146,17 @@ public String getName() { return this.config.getName(); } + /** + * Gets the parsed dependencies of this config. + * + *

+ * The returned containers are an internal representation; use {@link ConfigContainer#isSatisfied()} to + * query whether the config dependencies are satisfied. + *

+ * + * @return The parsed dependency containers. + */ + @ApiStatus.Internal public ImmutableList> getDependencies() { return ImmutableList.copyOf(this.dependencies); } @@ -192,7 +204,13 @@ public String modifyComment(String comment) { if (!this.dependencies.stream().allMatch(DependenciesContainer::isSatisfied)) { InfoNode rootNode = new InfoNode(null, GuiBase.TXT_GRAY + I18n.tr("magiclib.config.gui.dependencies_footer")); - ConfigContainer.generateDependencyCheckMessage(this.dependencies, rootNode); + MiscUtil.generateDependencyCheckMessage( + this.dependencies, + rootNode, + true, + text -> GuiBase.TXT_GRAY + text + GuiBase.TXT_RST, + result -> (result.isSuccess() ? GuiBase.TXT_GREEN : GuiBase.TXT_RED) + result.getReason() + ); comment += "\n" + rootNode.toString().replaceAll("\t", " "); } @@ -212,54 +230,4 @@ public String modifyComment(String comment) { return comment; } - - private static void generateDependencyCheckMessage( - @NotNull List> dependencies, InfoNode rootNode) { - boolean first = true; - boolean composite = false; - InfoNode compositeNode = new InfoNode(null, GuiBase.TXT_GRAY - + I18n.tr("magiclib.dependency.label.composite") + GuiBase.TXT_RST); - - for (DependenciesContainer dependenciesContainer : dependencies) { - List conflict = dependenciesContainer.checkConflict(); - List require = dependenciesContainer.checkRequire(); - InfoNode orNode = null; - - if (first) { - first = false; - } else if (!conflict.isEmpty() || !require.isEmpty()) { - if (!composite) { - for (InfoNode child : rootNode.getChildren()) { - child.moveTo(compositeNode); - } - - compositeNode.moveTo(rootNode); - composite = true; - } - - orNode = new InfoNode(rootNode, GuiBase.TXT_GRAY - + I18n.tr("magiclib.dependency.label.or") + GuiBase.TXT_RST); - } - - if (!conflict.isEmpty()) { - InfoNode conflictNode = new InfoNode(orNode == null ? rootNode : orNode, - GuiBase.TXT_GRAY + I18n.tr("magiclib.dependency.label.conflict")); - - for (DependencyCheckResult result : conflict) { - new InfoNode(conflictNode, (result.isSuccess() ? GuiBase.TXT_GREEN : GuiBase.TXT_RED) - + result.getReason()); - } - } - - if (!require.isEmpty()) { - InfoNode requireNode = new InfoNode(orNode == null ? rootNode : orNode, - GuiBase.TXT_GRAY + I18n.tr("magiclib.dependency.label.require")); - - for (DependencyCheckResult result : require) { - new InfoNode(requireNode, (result.isSuccess() ? GuiBase.TXT_GREEN : GuiBase.TXT_RED) - + result.getReason()); - } - } - } - } }