From 9570097d5bec246105e67e3481b83497b24c848d Mon Sep 17 00:00:00 2001
From: Kir_Antipov <kp.antipov@gmail.com>
Date: Fri, 25 Nov 2022 01:32:23 +0300
Subject: [PATCH] `FabricModMetadata` refactoring

Fixes #36
---
 README.md                                     |  52 +----
 src/metadata/fabric/fabric-mod-config.ts      |  57 +++++
 .../fabric/fabric-mod-metadata-reader.ts      |  15 +-
 src/metadata/fabric/fabric-mod-metadata.ts    | 194 +++++++++++++-----
 test/content/fabric/fabric.mod.json           |  19 +-
 .../metadata/mod-metadata-reader.test.ts      |   6 +
 6 files changed, 223 insertions(+), 120 deletions(-)
 create mode 100644 src/metadata/fabric/fabric-mod-config.ts

diff --git a/README.md b/README.md
index b89876e..084bdf0 100644
--- a/README.md
+++ b/README.md
@@ -167,7 +167,7 @@ Can be automatically retrieved from the config file of your mod:
 
 - `fabric.mod.json` (Fabric)
 
-  - Custom `mc-publish` field *(recommended)*:
+  - Custom `mc-publish` field:
       ```json
       {
         // ...
@@ -179,7 +179,7 @@ Can be automatically retrieved from the config file of your mod:
       }
       ```
 
-  - Custom [`modmanager`](https://github.com/DeathsGun/ModManager) field *(recommended)*:
+  - Custom [`modmanager`](https://github.com/DeathsGun/ModManager) field:
       ```json
       {
         // ...
@@ -191,28 +191,6 @@ Can be automatically retrieved from the config file of your mod:
       }
       ```
 
-  - Custom `projects` field:
-      ```json
-      {
-        // ...
-        "custom": {
-          "projects": {
-            "modrinth": "AANobbMI"
-          }
-        },
-      }
-      ```
-
-  - `projects` field:
-      ```json
-      {
-        // ...
-        "projects": {
-          "modrinth": "AANobbMI"
-        },
-      }
-      ```
-
 - `mods.toml` (Forge)
 
   - Custom `mc-publish` field *(recommended)*:
@@ -307,7 +285,7 @@ Can be automatically retrieved from the config file of your mod:
 
 - `fabric.mod.json` (Fabric)
 
-  - Custom `mc-publish` field *(recommended)*:
+  - Custom `mc-publish` field:
       ```json
       {
         // ...
@@ -319,7 +297,7 @@ Can be automatically retrieved from the config file of your mod:
       }
       ```
 
-  - Custom [`modmanager`](https://github.com/DeathsGun/ModManager) field *(recommended)*:
+  - Custom [`modmanager`](https://github.com/DeathsGun/ModManager) field:
       ```json
       {
         // ...
@@ -331,28 +309,6 @@ Can be automatically retrieved from the config file of your mod:
       }
       ```
 
-  - Custom `projects` field:
-      ```json
-      {
-        // ...
-        "custom": {
-          "projects": {
-            "curseforge": 394468
-          }
-        },
-      }
-      ```
-
-  - `projects` field:
-      ```json
-      {
-        // ...
-        "projects": {
-          "curseforge": 394468
-        },
-      }
-      ```
-
 - `mods.toml` (Forge)
 
   - Custom `mc-publish` field *(recommended)*:
diff --git a/src/metadata/fabric/fabric-mod-config.ts b/src/metadata/fabric/fabric-mod-config.ts
new file mode 100644
index 0000000..29600f8
--- /dev/null
+++ b/src/metadata/fabric/fabric-mod-config.ts
@@ -0,0 +1,57 @@
+import type DependencyKind from "../dependency-kind";
+
+type Environment = "client" | "server" | "*";
+
+type Person = string | { name: string; contact?: string; };
+
+type Dependency = string | string[] | {
+    version?: string | string[];
+    versions?: string | string[];
+    custom?: Record<string, any>;
+};
+
+type DependencyContainer = {
+    // Dependency resolution
+    [Kind in keyof typeof DependencyKind as Lowercase<Kind>]?: Record<string, Dependency>;
+};
+
+// https://fabricmc.net/wiki/documentation:fabric_mod_json
+type FabricModConfig = {
+    // Mandatory fields
+    schemaVersion: 1;
+    id: string;
+    version: string;
+
+    // Mod loading
+    provides?: string;
+    environment?: Environment;
+    entrypoints?: Record<string, string[]>;
+    jars?: { file: string }[];
+    languageAdapters?: Record<string, string>;
+    mixins?: (string | { config: string, environment: Environment })[];
+
+    // Metadata
+    name?: string;
+    description?: string;
+    contact?: {
+        email?: string;
+        irc?: string;
+        homepage?: string;
+        issues?: string;
+        sources?: string;
+        [key: string]: string;
+    };
+    authors?: Person[];
+    contributors?: Person[];
+    license?: string | string[];
+    icon?: string | Record<string, string>;
+
+    // Custom fields
+    custom?: Record<string, any>;
+} & DependencyContainer;
+
+namespace FabricModConfig {
+    export const FILENAME = "fabric.mod.json";
+}
+
+export default FabricModConfig;
diff --git a/src/metadata/fabric/fabric-mod-metadata-reader.ts b/src/metadata/fabric/fabric-mod-metadata-reader.ts
index fbe4a6a..147847d 100644
--- a/src/metadata/fabric/fabric-mod-metadata-reader.ts
+++ b/src/metadata/fabric/fabric-mod-metadata-reader.ts
@@ -1,17 +1,20 @@
-import ModMetadata from "../../metadata/mod-metadata";
-import ZippedModMetadataReader from "../../metadata/zipped-mod-metadata-reader";
+import ModMetadata from "../mod-metadata";
+import ZippedModMetadataReader from "../zipped-mod-metadata-reader";
+import FabricModConfig from "./fabric-mod-config";
 import FabricModMetadata from "./fabric-mod-metadata";
 
-export default class FabricModMetadataReader extends ZippedModMetadataReader {
+class FabricModMetadataReader extends ZippedModMetadataReader<FabricModConfig> {
     constructor() {
-        super("fabric.mod.json");
+        super(FabricModConfig.FILENAME);
     }
 
-    protected loadConfig(buffer: Buffer): Record<string, unknown> {
+    protected loadConfig(buffer: Buffer): FabricModConfig {
         return JSON.parse(buffer.toString("utf8"));
     }
 
-    protected createMetadataFromConfig(config: Record<string, unknown>): ModMetadata {
+    protected createMetadataFromConfig(config: FabricModConfig): ModMetadata {
         return new FabricModMetadata(config);
     }
 }
+
+export default FabricModMetadataReader;
diff --git a/src/metadata/fabric/fabric-mod-metadata.ts b/src/metadata/fabric/fabric-mod-metadata.ts
index c411171..9cc4da5 100644
--- a/src/metadata/fabric/fabric-mod-metadata.ts
+++ b/src/metadata/fabric/fabric-mod-metadata.ts
@@ -1,76 +1,158 @@
 import action from "../../../package.json";
-import ModConfig from "../../metadata/mod-config";
-import ModConfigDependency from "../../metadata/mod-config-dependency";
-import Dependency from "../../metadata/dependency";
-import DependencyKind from "../../metadata/dependency-kind";
+import Dependency from "../dependency";
+import DependencyKind from "../dependency-kind";
 import PublisherTarget from "../../publishing/publisher-target";
+import FabricModConfig from "./fabric-mod-config";
+import ModMetadata from "../mod-metadata";
 
-const ignoredByDefault = ["minecraft", "java", "fabricloader"];
-const aliases = new Map([
-    ["fabric", "fabric-api"]
-]);
-function getDependenciesByKind(config: any, kind: DependencyKind): Dependency[] {
-    const kindName = DependencyKind.toString(kind).toLowerCase();
+type Aliases = Map<PublisherTarget, string>;
+
+type FabricDependency = FabricModConfig["depends"][string];
+
+function getDependencies(config: FabricModConfig): Dependency[] {
+    return DependencyKind.getValues().flatMap(x => getDependenciesByKind(config, x));
+}
+
+function getDependenciesByKind(config: FabricModConfig, kind: DependencyKind): Dependency[] {
+    const kindName = DependencyKind.toString(kind).toLowerCase() as Lowercase<keyof typeof DependencyKind>;
     const dependencies = new Array<Dependency>();
     for (const [id, value] of Object.entries(config[kindName] || {})) {
-        const ignore = ignoredByDefault.includes(id);
-        if (typeof value === "string") {
-            const dependencyAliases = aliases.has(id) ? new Map(PublisherTarget.getValues().map(x => [x, aliases.get(id)])) : null;
-            dependencies.push(Dependency.create({ id, kind, version: value, ignore, aliases: dependencyAliases }));
-        } else {
-            const dependencyMetadata = { ignore, ...<any>value, id, kind };
-            if (aliases.has(id)) {
-                if (!dependencyMetadata.custom) {
-                    dependencyMetadata.custom = {};
-                }
-                if (!dependencyMetadata.custom[action.name]) {
-                    dependencyMetadata.custom[action.name] = {};
-                }
-                for (const target of PublisherTarget.getValues()) {
-                    const targetName = PublisherTarget.toString(target).toLowerCase();
-                    if (typeof dependencyMetadata.custom[action.name][targetName] !== "string") {
-                        dependencyMetadata.custom[action.name][targetName] = aliases.get(id);
-                    }
-                }
-            }
-            dependencies.push(new ModConfigDependency(dependencyMetadata));
-        }
+        const ignore = isDependencyIgnoredByDefault(id);
+        const aliases = getDefaultDependencyAliases(id);
+        const dependency = parseDependency(id, kind, value, ignore, aliases, config);
+
+        dependencies.push(dependency);
     }
     return dependencies;
 }
 
-function getLoaders(config: any): string[] {
-    if (config[action.name]?.quilt ?? config.custom?.[action.name]?.quilt) {
+function parseDependency(id: string, kind: DependencyKind, body: FabricDependency, ignore: boolean, aliases: Aliases, config: FabricModConfig): Dependency {
+    if (typeof body === "string" || Array.isArray(body)) {
+        return parseSimpleDependency(id, kind, body, ignore, aliases, config);
+    }
+
+    let version = body.version || body.versions || "*";
+    if (Array.isArray(version)) {
+        version = version.join(" || ");
+    }
+
+    kind = getDependencyKind(id, config) ?? kind;
+    ignore = isDependencyIgnoredInConfig(id, config) ?? body.custom?.[action.name]?.ignore ?? ignore;
+    aliases = new Map([ ...(aliases || []), ...(getDependencyAliases(id, config) || []) ]);
+    for (const target of PublisherTarget.getValues()) {
+        const targetName = PublisherTarget.toString(target).toLowerCase();
+        const alias = body.custom?.[action.name]?.[targetName];
+        if (alias) {
+            aliases.set(target, String(alias));
+        }
+    }
+
+    return Dependency.create({ id, kind, version, ignore, aliases });
+}
+
+function parseSimpleDependency(id: string, kind: DependencyKind, version: string | string[], ignore: boolean, aliases: Aliases, config: FabricModConfig): Dependency {
+    if (Array.isArray(version)) {
+        version = version.join(" || ");
+    }
+
+    kind = getDependencyKind(id, config) ?? kind;
+    ignore = isDependencyIgnoredInConfig(id, config) ?? ignore;
+    aliases = new Map([ ...(aliases || []), ...(getDependencyAliases(id, config) || []) ]);
+    return Dependency.create({ id, kind, version, ignore, aliases });
+}
+
+const ignoredByDefault = [
+    "minecraft",
+    "java",
+    "fabricloader",
+];
+function isDependencyIgnoredByDefault(id: string): boolean {
+    return ignoredByDefault.includes(id);
+}
+
+function isDependencyIgnoredInConfig(id: string, config: FabricModConfig): boolean | null {
+    return config.custom?.[action.name]?.dependencies?.[id]?.ignore;
+}
+
+const defaultAliases = new Map<string, string | Aliases>([
+    ["fabric", "fabric-api"],
+]);
+function getDefaultDependencyAliases(id: string): Aliases | null {
+    if (!defaultAliases.has(id)) {
+        return null;
+    }
+
+    const aliases = defaultAliases.get(id);
+    if (typeof aliases !== "string") {
+        return new Map([...aliases]);
+    }
+
+    return new Map(PublisherTarget.getValues().map(x => [x, aliases]));
+}
+
+function getDependencyAliases(id: string, config: FabricModConfig): Aliases | null {
+    const metadata = config.custom?.[action.name]?.dependencies?.[id];
+    if (!metadata) {
+        return null;
+    }
+
+    const aliases = new Map() as Aliases;
+    for (const target of PublisherTarget.getValues()) {
+        const targetName = PublisherTarget.toString(target).toLowerCase();
+        const alias = metadata[targetName] ?? id;
+        aliases.set(target, String(alias));
+    }
+    return aliases;
+}
+
+function getDependencyKind(id: string, config: FabricModConfig): DependencyKind | null {
+    const kind = config.custom?.[action.name]?.dependencies?.[id]?.kind;
+    return kind ? DependencyKind.parse(kind) : null;
+}
+
+function getLoaders(config: FabricModConfig): string[] {
+    if (config.custom?.[action.name]?.quilt) {
         return ["fabric", "quilt"];
     }
     return ["fabric"];
 }
 
-export default class FabricModMetadata extends ModConfig {
-    public readonly id: string;
-    public readonly name: string;
-    public readonly version: string;
-    public readonly loaders: string[];
-    public readonly dependencies: Dependency[];
+function getProjects(config: FabricModConfig): Map<PublisherTarget, string> {
+    const projects = new Map();
+    for (const target of PublisherTarget.getValues()) {
+        const targetName = PublisherTarget.toString(target).toLowerCase();
+        const projectId = config.custom?.[action.name]?.[targetName]
+            ?? config.custom?.modmanager?.[targetName]
+            ?? config.custom?.projects?.[targetName];
 
-    constructor(config: Record<string, unknown>) {
-        super(config);
-        this.id = String(this.config.id ?? "");
-        this.name = String(this.config.name ?? this.id);
-        this.version = String(this.config.version ?? "*");
-        this.loaders = getLoaders(this.config);
-        this.dependencies = DependencyKind.getValues().flatMap(x => getDependenciesByKind(this.config, x));
+        if (projectId) {
+            projects.set(target, String(projectId));
+        }
+    }
+    return projects;
+}
+
+class FabricModMetadata implements ModMetadata {
+    readonly id: string;
+    readonly name: string;
+    readonly version: string;
+    readonly loaders: string[];
+    readonly dependencies: Dependency[];
+
+    private readonly _projects: Map<PublisherTarget, string>;
+
+    constructor(config: FabricModConfig) {
+        this.id = String(config.id ?? "");
+        this.name = String(config.name ?? this.id);
+        this.version = String(config.version ?? "*");
+        this.loaders = getLoaders(config);
+        this.dependencies = getDependencies(config);
+        this._projects = getProjects(config);
     }
 
     getProjectId(project: PublisherTarget): string | undefined {
-        const projectId = super.getProjectId(project);
-        if (projectId) {
-            return projectId;
-        }
-
-        const projectName = PublisherTarget.toString(project).toLowerCase();
-        const custom = <any>this.config.custom;
-        const modManagerProjectId = custom?.modmanager?.[projectName]?.id ?? custom?.modmanager?.[projectName];
-        return modManagerProjectId === undefined ? modManagerProjectId : String(modManagerProjectId);
+        return this._projects.get(project);
     }
 }
+
+export default FabricModMetadata;
diff --git a/test/content/fabric/fabric.mod.json b/test/content/fabric/fabric.mod.json
index 7659725..197eef5 100644
--- a/test/content/fabric/fabric.mod.json
+++ b/test/content/fabric/fabric.mod.json
@@ -23,10 +23,10 @@
       "example-mod.mixins.json"
     ],
 
-    "projects": {
-      "modrinth": "AANobbMI"
-    },
     "custom": {
+      "modmanager": {
+        "modrinth": "AANobbMI"
+      },
       "projects": {
         "curseforge": 394468
       },
@@ -38,20 +38,19 @@
     "depends": {
       "fabricloader": ">=0.11.3",
       "fabric": ">=0.40.0",
-      "minecraft": "1.17.x",
+      "minecraft": [
+        "1.17",
+        "1.17.1"
+      ],
       "java": ">=16"
     },
     "recommends": {
       "recommended-mod": {
         "version": "0.2.0",
-        "projects": {
-          "modrinth": "AAAA"
-        },
         "custom": {
-          "projects": {
-            "curseforge": 42
-          },
           "mc-publish": {
+            "modrinth": "AAAA",
+            "curseforge": 42,
             "github": "v0.2.0",
             "ignore": true
           }
diff --git a/test/unit-tests/metadata/mod-metadata-reader.test.ts b/test/unit-tests/metadata/mod-metadata-reader.test.ts
index 0fcb720..56add9d 100644
--- a/test/unit-tests/metadata/mod-metadata-reader.test.ts
+++ b/test/unit-tests/metadata/mod-metadata-reader.test.ts
@@ -65,6 +65,12 @@ describe("ModMetadataReader.readMetadata", () => {
             }
         });
 
+        test("version array is supported", async () => {
+            const metadata = (await ModMetadataReader.readMetadata("example-mod.fabric.jar"))!;
+            const minecraft = metadata.dependencies.find(x => x.id === "minecraft");
+            expect(minecraft.version).toStrictEqual("1.17 || 1.17.1");
+        });
+
         test("custom metadata can be attached to dependency entry", async () => {
             const metadata = (await ModMetadataReader.readMetadata("example-mod.fabric.jar"))!;
             const recommended = metadata.dependencies.find(x => x.id === "recommended-mod")!;