From 0fcfdc07c1fe370e95e1d50a523ef60fe4c2cecb Mon Sep 17 00:00:00 2001
From: Kir_Antipov <kp.antipov@gmail.com>
Date: Sun, 5 Jun 2022 19:37:01 +0300
Subject: [PATCH] Implemented Quilt support

Closes #5
---
 src/metadata/mod-config-dependency.ts         |  6 +-
 src/metadata/mod-config.ts                    |  6 +-
 src/metadata/mod-loader-type.ts               |  1 +
 src/metadata/mod-metadata-reader-factory.ts   |  4 +
 .../quilt/quilt-mod-metadata-reader.ts        | 17 ++++
 src/metadata/quilt/quilt-mod-metadata.ts      | 97 +++++++++++++++++++
 test/content/quilt.mod.json                   | 89 +++++++++++++++++
 test/curseforge-utils.test.ts                 |  1 +
 test/mod-metadata-reader.test.ts              | 86 ++++++++++++++++
 9 files changed, 303 insertions(+), 4 deletions(-)
 create mode 100644 src/metadata/quilt/quilt-mod-metadata-reader.ts
 create mode 100644 src/metadata/quilt/quilt-mod-metadata.ts
 create mode 100644 test/content/quilt.mod.json

diff --git a/src/metadata/mod-config-dependency.ts b/src/metadata/mod-config-dependency.ts
index a957a70..12326f1 100644
--- a/src/metadata/mod-config-dependency.ts
+++ b/src/metadata/mod-config-dependency.ts
@@ -27,9 +27,11 @@ export default class ModConfigDependency<TMetadata extends DependencyOptions = R
 
     getProjectSlug(project: PublisherTarget): string {
         const projectName = PublisherTarget.toString(project).toLowerCase();
-        const custom = this.metadata["custom"];
-        const projects = this.metadata["projects"];
+        const metadata = this.metadata;
+        const custom = metadata["custom"];
+        const projects = metadata["projects"];
         return String(
+            metadata[action.name]?.[projectName]?.slug ?? metadata[action.name]?.[projectName] ??
             custom?.[action.name]?.[projectName]?.slug ?? custom?.[action.name]?.[projectName] ??
             projects?.[projectName]?.slug ?? projects?.[projectName] ??
             custom?.projects?.[projectName]?.slug ?? custom?.projects?.[projectName] ??
diff --git a/src/metadata/mod-config.ts b/src/metadata/mod-config.ts
index 9f385c3..54f352c 100644
--- a/src/metadata/mod-config.ts
+++ b/src/metadata/mod-config.ts
@@ -18,9 +18,11 @@ export default abstract class ModConfig<TConfig = Record<string, unknown>> imple
 
     getProjectId(project: PublisherTarget): string | undefined {
         const projectName = PublisherTarget.toString(project).toLowerCase();
-        const custom = this.config["custom"];
-        const projects = this.config["projects"];
+        const config = this.config;
+        const custom = config["custom"];
+        const projects = config["projects"];
         const projectId = (
+            config[action.name]?.[projectName]?.id ?? config[action.name]?.[projectName] ??
             custom?.[action.name]?.[projectName]?.id ?? custom?.[action.name]?.[projectName] ??
             projects?.[projectName]?.id ?? projects?.[projectName] ??
             custom?.projects?.[projectName]?.id ?? custom?.projects?.[projectName]
diff --git a/src/metadata/mod-loader-type.ts b/src/metadata/mod-loader-type.ts
index 9215497..c51e60f 100644
--- a/src/metadata/mod-loader-type.ts
+++ b/src/metadata/mod-loader-type.ts
@@ -1,6 +1,7 @@
 enum ModLoaderType {
     Fabric = 1,
     Forge,
+    Quilt,
 }
 
 namespace ModLoaderType {
diff --git a/src/metadata/mod-metadata-reader-factory.ts b/src/metadata/mod-metadata-reader-factory.ts
index f8b2921..faef2ba 100644
--- a/src/metadata/mod-metadata-reader-factory.ts
+++ b/src/metadata/mod-metadata-reader-factory.ts
@@ -1,5 +1,6 @@
 import FabricModMetadataReader from "./fabric/fabric-mod-metadata-reader";
 import ForgeModMetadataReader from "./forge/forge-mod-metadata-reader";
+import QuiltModMetadataReader from "./quilt/quilt-mod-metadata-reader";
 import ModLoaderType from "./mod-loader-type";
 import ModMetadataReader from "./mod-metadata-reader";
 
@@ -12,6 +13,9 @@ export default class ModMetadataReaderFactory {
             case ModLoaderType.Forge:
                 return new ForgeModMetadataReader();
 
+            case ModLoaderType.Quilt:
+                return new QuiltModMetadataReader();
+
             default:
                 throw new Error(`Unknown mod loader "${ModLoaderType.toString(loaderType)}"`);
         }
diff --git a/src/metadata/quilt/quilt-mod-metadata-reader.ts b/src/metadata/quilt/quilt-mod-metadata-reader.ts
new file mode 100644
index 0000000..caac99a
--- /dev/null
+++ b/src/metadata/quilt/quilt-mod-metadata-reader.ts
@@ -0,0 +1,17 @@
+import ModMetadata from "../../metadata/mod-metadata";
+import ZippedModMetadataReader from "../../metadata/zipped-mod-metadata-reader";
+import QuiltModMetadata from "./quilt-mod-metadata";
+
+export default class QuiltModMetadataReader extends ZippedModMetadataReader {
+    constructor() {
+        super("quilt.mod.json");
+    }
+
+    protected loadConfig(buffer: Buffer): Record<string, unknown> {
+        return JSON.parse(buffer.toString("utf8"));
+    }
+
+    protected createMetadataFromConfig(config: Record<string, unknown>): ModMetadata {
+        return new QuiltModMetadata(config);
+    }
+}
diff --git a/src/metadata/quilt/quilt-mod-metadata.ts b/src/metadata/quilt/quilt-mod-metadata.ts
new file mode 100644
index 0000000..92dd098
--- /dev/null
+++ b/src/metadata/quilt/quilt-mod-metadata.ts
@@ -0,0 +1,97 @@
+import action from "../../../package.json";
+import Dependency from "../../metadata/dependency";
+import DependencyKind from "../../metadata/dependency-kind";
+import ModConfig from "../../metadata/mod-config";
+import ModConfigDependency from "../../metadata/mod-config-dependency";
+import PublisherTarget from "../../publishing/publisher-target";
+
+function extractId(id?: string): string | null {
+    if (!id) {
+        return id ?? null;
+    }
+
+    const separatorIndex = id.indexOf(":");
+    if (separatorIndex !== -1) {
+        id = id.substring(separatorIndex + 1);
+    }
+
+    return id;
+}
+
+function getDependencyEntries(container: any, transformer?: (x: any) => void): any[] {
+    if (!Array.isArray(container)) {
+        return [];
+    }
+
+    if (transformer) {
+        container = container.map(x => typeof x === "string" ? ({ id: x }) : ({ ...x }));
+        container.forEach(transformer);
+    }
+    return container;
+}
+
+const ignoredByDefault = ["minecraft", "java", "quilt_loader"];
+const aliases = new Map([
+    ["fabric", "fabric-api"],
+    ["quilted_fabric_api", "qsl"],
+]);
+function createDependency(body: any): Dependency {
+    const id = extractId(typeof body === "string" ? body : String(body.id ?? ""));
+    const ignore = ignoredByDefault.includes(id);
+    if (id.startsWith("quilted_") || id.startsWith("quilt_")) {
+        aliases.set(id, "qsl");
+    }
+
+    if (typeof body === "string") {
+        const dependencyAliases = aliases.has(id) ? new Map(PublisherTarget.getValues().map(x => [x, aliases.get(id)])) : null;
+        return Dependency.create({ id, ignore, aliases: dependencyAliases });
+    }
+
+    const dependencyMetadata = {
+        ignore,
+        ...body,
+        id,
+        version: body.version ?? String(Array.isArray(body.versions) ? body.versions[0] : body.versions || "*"),
+        kind: (
+            body.incompatible && body.unless && DependencyKind.Conflicts ||
+            body.incompatible && DependencyKind.Breaks ||
+            body.embedded && DependencyKind.Includes ||
+            body.optional && DependencyKind.Recommends ||
+            DependencyKind.Depends
+        )
+    };
+    if (aliases.has(id)) {
+        if (!dependencyMetadata[action.name]) {
+            dependencyMetadata[action.name] = {};
+        }
+        for (const target of PublisherTarget.getValues()) {
+            const targetName = PublisherTarget.toString(target).toLowerCase();
+            if (typeof dependencyMetadata[action.name][targetName] !== "string") {
+                dependencyMetadata[action.name][targetName] = aliases.get(id);
+            }
+        }
+    }
+    return new ModConfigDependency(dependencyMetadata);
+}
+
+export default class QuiltModMetadata extends ModConfig {
+    public readonly id: string;
+    public readonly name: string;
+    public readonly version: string;
+    public readonly loaders: string[];
+    public readonly dependencies: Dependency[];
+
+    constructor(config: Record<string, unknown>) {
+        super(config);
+        const root = <Record<string, unknown>>this.config.quilt_loader ?? {};
+        this.id = String(root.id ?? "");
+        this.name = String(root.name ?? this.id);
+        this.version = String(root.version ?? "*");
+        this.loaders = ["quilt"];
+        this.dependencies = getDependencyEntries(root.depends)
+            .concat(getDependencyEntries(root.provides, x => x.embedded = true))
+            .concat(getDependencyEntries(root.breaks, x => x.incompatible = true))
+            .map(createDependency)
+            .filter((x, i, self) => self.findIndex(y => x.id === y.id && x.kind === y.kind) === i);
+    }
+}
\ No newline at end of file
diff --git a/test/content/quilt.mod.json b/test/content/quilt.mod.json
new file mode 100644
index 0000000..1b3f1e1
--- /dev/null
+++ b/test/content/quilt.mod.json
@@ -0,0 +1,89 @@
+{
+  "schema_version": 1,
+  "quilt_loader": {
+    "group": "com.example",
+    "id": "example-mod",
+    "version": "0.1.0",
+    "name": "Example Mod",
+    "description": "Description",
+    "authors": [
+      "Author"
+    ],
+    "contact": {
+      "homepage": "https://github.com/",
+      "sources": "https://github.com/",
+      "issues": "https://github.com/",
+      "wiki": "https://github.com/"
+    },
+    "license": "MIT",
+    "icon": "icon.jpg",
+    "intermediate_mappings": "net.fabricmc:intermediary",
+    "environment": "*",
+    "entrypoints": {
+      "main": [
+        "example.ExampleMod"
+      ]
+    },
+    "depends": [
+      {
+        "id": "quilt_loader",
+        "version": ">=0.11.3"
+      },
+      {
+        "id": "quilt_base",
+        "version": ">=0.40.0"
+      },
+      {
+        "id": "minecraft",
+        "version": "1.17.x"
+      },
+      {
+        "id": "java",
+        "version": ">=16"
+      },
+      {
+        "id": "recommended-mod",
+        "version": "0.2.0",
+        "optional": true,
+        "mc-publish": {
+          "modrinth": "AAAA",
+          "ignore": true
+        },
+        "projects": {
+          "curseforge": 42
+        },
+        "custom": {
+          "projects": {
+            "github": "v0.2.0"
+          }
+        }
+      }
+    ],
+    "provides": [
+      "included:included-mod"
+    ],
+    "breaks": [
+      "breaking-mod",
+      {
+        "id": "conflicting:conflicting-mod",
+        "version": "<0.40.0",
+        "unless": "fix-conflicting-mod"
+      }
+    ]
+  },
+  "mc-publish": {
+    "modrinth": "AANobbMI"
+  },
+  "projects": {
+    "curseforge": 394468
+  },
+  "custom": {
+    "projects": {
+      "github": "mc1.18-0.4.0-alpha5"
+    }
+  },
+  "mixins": [
+    "example-mod.mixins.json"
+  ],
+  "access_widener": "example.accesswidener"
+}
diff --git a/test/curseforge-utils.test.ts b/test/curseforge-utils.test.ts
index 7552e8e..1c211a7 100644
--- a/test/curseforge-utils.test.ts
+++ b/test/curseforge-utils.test.ts
@@ -80,6 +80,7 @@ describe("convertToCurseForgeVersions", () => {
             loaders: {
                 fabric: 7499,
                 forge: 7498,
+                quilt: 9153,
                 rift: 7500
             },
             java: {
diff --git a/test/mod-metadata-reader.test.ts b/test/mod-metadata-reader.test.ts
index d81c65a..fab477b 100644
--- a/test/mod-metadata-reader.test.ts
+++ b/test/mod-metadata-reader.test.ts
@@ -170,6 +170,92 @@ describe("ModMetadataReader.readMetadata", () => {
         });
     });
 
+    describe("Quilt", () => {
+        beforeAll(() => new Promise(resolve => {
+            const zip = new ZipFile();
+            zip.addFile("./test/content/quilt.mod.json", "quilt.mod.json");
+            zip.end();
+            zip.outputStream.pipe(fs.createWriteStream("example-mod.quilt.jar")).on("close", resolve);
+        }));
+
+        afterAll(() => new Promise(resolve => fs.unlink("example-mod.quilt.jar", resolve)));
+
+        test("the format can be read", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            expect(metadata).toBeTruthy();
+        });
+
+        test("mod info can be read", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            expect(metadata.id).toBe("example-mod");
+            expect(metadata.name).toBe("Example Mod");
+            expect(metadata.version).toBe("0.1.0");
+            expect(metadata.loaders).toMatchObject(["quilt"]);
+        });
+
+        test("project ids can be specified in the config file", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            expect(metadata.getProjectId(PublisherTarget.Modrinth)).toBe("AANobbMI");
+            expect(metadata.getProjectId(PublisherTarget.CurseForge)).toBe("394468");
+            expect(metadata.getProjectId(PublisherTarget.GitHub)).toBe("mc1.18-0.4.0-alpha5");
+        });
+
+        test("all dependencies are read", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            expect(metadata.dependencies).toHaveLength(8);
+            const dependencies = metadata.dependencies.reduce((agg, x) => { agg[x.id] = x; return agg; }, <Record<string, Dependency>>{});
+            expect(dependencies["quilt_loader"]?.kind).toBe(DependencyKind.Depends);
+            expect(dependencies["quilt_base"]?.kind).toBe(DependencyKind.Depends);
+            expect(dependencies["minecraft"]?.kind).toBe(DependencyKind.Depends);
+            expect(dependencies["java"]?.kind).toBe(DependencyKind.Depends);
+            expect(dependencies["recommended-mod"]?.kind).toBe(DependencyKind.Recommends);
+            expect(dependencies["included-mod"]?.kind).toBe(DependencyKind.Includes);
+            expect(dependencies["conflicting-mod"]?.kind).toBe(DependencyKind.Conflicts);
+            expect(dependencies["breaking-mod"]?.kind).toBe(DependencyKind.Breaks);
+        });
+
+        test("dependency info can be read", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            const conflicting = metadata.dependencies.find(x => x.id === "conflicting-mod");
+            expect(conflicting).toBeTruthy();
+            expect(conflicting.id).toBe("conflicting-mod");
+            expect(conflicting.kind).toBe(DependencyKind.Conflicts);
+            expect(conflicting.version).toBe("<0.40.0");
+            expect(conflicting.ignore).toBe(false);
+            for (const project of PublisherTarget.getValues()) {
+                expect(conflicting.getProjectSlug(project)).toBe(conflicting.id);
+            }
+        });
+
+        test("custom metadata can be attached to dependency entry", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            const recommended = metadata.dependencies.find(x => x.id === "recommended-mod");
+            expect(recommended).toBeTruthy();
+            expect(recommended.id).toBe("recommended-mod");
+            expect(recommended.kind).toBe(DependencyKind.Recommends);
+            expect(recommended.version).toBe("0.2.0");
+            expect(recommended.ignore).toBe(true);
+            expect(recommended.getProjectSlug(PublisherTarget.Modrinth)).toBe("AAAA");
+            expect(recommended.getProjectSlug(PublisherTarget.CurseForge)).toBe("42");
+            expect(recommended.getProjectSlug(PublisherTarget.GitHub)).toBe("v0.2.0");
+        });
+
+        test("special case dependencies (minecraft, java and quilt_loader) are ignored by default", async () => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            expect(metadata.dependencies.find(x => x.id === "minecraft").ignore).toBe(true);
+            expect(metadata.dependencies.find(x => x.id === "java").ignore).toBe(true);
+            expect(metadata.dependencies.find(x => x.id === "quilt_loader").ignore).toBe(true);
+        });
+
+        test("special case dependencies (quilted_quilt_api) are replaced with their aliases", async() => {
+            const metadata = await ModMetadataReader.readMetadata("example-mod.quilt.jar");
+            const quilt = metadata.dependencies.find(x => x.id === "quilt_base");
+            for (const target of PublisherTarget.getValues()) {
+                expect(quilt.getProjectSlug(target) === "qsl");
+            }
+        });
+    });
+
     describe("unsupported mod formats", () => {
         test("null is returned when the format is not supported or specified file does not exist", async () => {
             const metadata = await ModMetadataReader.readMetadata("example-mod.unknown.jar");