Implemented logic to work with dependencies

This commit is contained in:
Kir_Antipov 2023-02-25 14:45:10 +00:00
parent 989ccf0c87
commit 002d721497
3 changed files with 556 additions and 0 deletions

View file

@ -0,0 +1,57 @@
// TODO: Drop support for the legacy format completely.
import { FabricDependencyType } from "@/loaders/fabric/fabric-dependency-type";
import { deprecate } from "node:util";
import { DependencyInfo } from "./dependency";
/**
* Checks if the provided dependency string is in the legacy format.
*
* @param dependency - The dependency string to check.
*
* @returns A boolean indicating if the string is in the legacy format.
*/
export function isLegacyDependencyFormat(dependency: string): boolean {
return !!dependency?.includes("|") && !dependency.includes("@");
}
/**
* Parses the legacy dependency format.
*
* @param dependencyFormat - The dependency string in the legacy format.
*
* @returns An object containing the parsed dependency info.
*
* @remarks
*
* The legacy format is: `[dependency-id] | [type]? | [version-range]?`
*/
function _parseLegacyDependencyFormat(dependencyFormat: string): DependencyInfo {
const [id, fabricType, versions] = dependencyFormat.split("|").map(x => x.trim());
const type = fabricType && FabricDependencyType.toDependencyType(FabricDependencyType.parse(fabricType));
return { id, type, versions };
}
/**
* Parses the legacy dependency format with a deprecation warning.
*
* @param dependencyFormat - The dependency string in the legacy format.
*
* @returns An object containing the parsed dependency info.
*
* @remarks
*
* The legacy format is: `[dependency-id] | [type]? | [version-range]?`
*
* @deprecated
*
* The old dependency string format is deprecated. Please use the new format.
*
* Example: `foo@1.0.0-2.0.0(required){modrinth:foo-fabric}#(ignore:curseforge)`.
*/
export const parseLegacyDependencyFormat = deprecate(
_parseLegacyDependencyFormat,
"The old dependency string format is deprecated. " +
"Please use the new format. " +
"Example: foo@1.0.0-2.0.0(required){modrinth:foo-fabric}#(ignore:curseforge)",
);

View file

@ -0,0 +1,225 @@
import { PlatformType } from "@/platforms/platform-type";
import { $i, isIterable } from "@/utils/collections";
import { VersionRange, anyVersionRange } from "@/utils/versioning";
import { DependencyType } from "./dependency-type";
import { isLegacyDependencyFormat, parseLegacyDependencyFormat } from "./dependency.legacy";
/**
* Represents a dependency.
*/
export interface Dependency {
/**
* The unique identifier of the dependency.
*/
get id(): string;
/**
* The importance of the dependency for the project.
*/
get type(): DependencyType;
/**
* The range of allowed versions for the dependency.
*/
get versions(): string[];
/**
* Checks if the dependency is ignored for a specific platform or globally
* if no platform is specified.
*
* @param platform - The platform to check for (optional).
*
* @returns A boolean indicating if the dependency is ignored.
*/
isIgnored(platform?: PlatformType): boolean;
/**
* Retrieves the project ID for the dependency on a specific platform.
*
* Useful when a dependency has different identifiers across platforms.
*
* @param platform - The platform to get the project ID for.
*
* @returns The project ID associated with the dependency on the specified platform.
*/
getProjectId(platform: PlatformType): string;
}
/**
* Represents an intermediate representation of a dependency
* when parsing and creating {@link Dependency} objects from various formats.
*/
export interface DependencyInfo {
/**
* The unique identifier of the dependency.
*/
id: string;
/**
* The importance of the dependency for the project.
*/
type?: string | DependencyType;
/**
* The range of allowed versions for the dependency.
*/
versions?: string | string[] | VersionRange;
/**
* A boolean indicating if the dependency is ignored globally.
*/
ignore?: boolean;
/**
* A list of platforms the dependency is ignored on.
*/
ignoredPlatforms?: Iterable<string | PlatformType>;
/**
* A list of aliases for the dependency on different platforms.
*/
aliases?: Iterable<readonly [string | PlatformType, string]>;
}
/**
* Representing different ways a dependency can be expressed.
*/
export type DependencyLike = Dependency | DependencyInfo | string;
/**
* Parses a dependency string and returns a Dependency object.
*
* @param dependency - The dependency string to parse.
*
* @returns A {@link Dependency} object, or `undefined` if the string is invalid.
*/
export function parseDependency(dependency: string): Dependency | undefined {
const dependencyInfo = isLegacyDependencyFormat(dependency)
? parseLegacyDependencyFormat(dependency)
: parseDependencyFormat(dependency);
return dependencyInfo && createDependency(dependencyInfo);
}
/**
* A regex pattern for matching formatted dependency strings.
*/
const DEPENDENCY_REGEX = /^\s*(?<id>[^@{(#]+)(@(?<versionRange>[^@{(#]*))?(?:\((?<type>[^@{(#]*)\))?(?<aliases>(?:\{[^:=]+(?:=|:)[^}]*\})+)?(?<ignore>#\(ignore(?::(?<ignoredPlatforms>[^\)]*))?\))?\s*$/;
/**
* A regex pattern for matching dependency aliases in dependency strings.
*/
const DEPENDENCY_ALIASES_REGEX = /\{(?<platform>[^:=]+)(?:=|:)(?<id>[^}]*)\}/g;
/**
* Parses a dependency string and returns an intermediate representation of a dependency.
*
* @param dependencyFormat - The dependency string to parse.
*
* @returns A dependency info, or `undefined` if the string is invalid.
*
* @remarks
*
* The format is `[dependency-id]@[version-range]?([type])?{[platform]:[dependency-id]}?#(ignore:[platform1,platform2])?`.
*/
function parseDependencyFormat(dependencyFormat: string): DependencyInfo | undefined {
const match = dependencyFormat?.match(DEPENDENCY_REGEX);
if (!match) {
return undefined;
}
const id = match.groups.id.trim();
const versions = match.groups.versionRange?.trim();
const type = match.groups.type?.trim();
const aliases = $i(match.groups.aliases?.matchAll(DEPENDENCY_ALIASES_REGEX) || []).map(x => [x.groups.platform.trim(), x.groups.id.trim()] as const);
const ignoredPlatforms = match.groups.ignoredPlatforms?.split(",").map(x => x.trim());
const ignore = ignoredPlatforms?.length ? undefined : !!match.groups.ignore;
return { id, versions, type, aliases, ignore, ignoredPlatforms };
}
/**
* Creates a dependency from the given dependency-like value.
*
* @param dependency - A dependency-like value to create a dependency from.
*
* @returns A {@link Dependency}, or `undefined` if the input is invalid.
*/
export function createDependency(dependency: DependencyLike): Dependency | undefined {
if (typeof dependency === "string") {
return parseDependency(dependency);
}
if (isDependency(dependency)) {
return dependency;
}
if (!dependency?.id) {
return undefined;
}
const id = dependency.id || "";
const type = dependency.type && DependencyType.parse(dependency.type) || DependencyType.REQUIRED;
const versionRanges = typeof dependency.versions === "string"
? [dependency.versions]
: isIterable(dependency.versions)
? [...dependency.versions]
: [(dependency.versions || anyVersionRange()).toString()];
const versions = versionRanges.filter(x => x && x !== anyVersionRange().toString());
if (!versions.length) {
versions.push(anyVersionRange().toString());
}
const ignoredPlatforms = $i(dependency.ignoredPlatforms || []).map(x => PlatformType.parse(x)).filter(x => x).toSet();
const isIgnored = dependency.ignore
? () => true
: (p: PlatformType) => p ? ignoredPlatforms.has(p) : ignoredPlatforms.size === PlatformType.size;
const aliases = $i(dependency.aliases || []).map(([key, value]) => [PlatformType.parse(key), value] as const).filter(([key]) => key).toMap();
const getProjectId = (p: PlatformType) => aliases.get(p) ?? id;
return { id, versions, type, isIgnored, getProjectId };
}
/**
* Formats a dependency as a string.
*
* @param dependency - The dependency to format.
*
* @returns A string representation of the dependency.
*/
export function formatDependency(dependency: Dependency): string {
if (!dependency) {
return "";
}
const versionRange = dependency.versions.join(" || ");
const version = versionRange && versionRange !== anyVersionRange().toString() ? `@${versionRange}` : "";
const ignoredBy = $i(PlatformType.values()).filter(x => dependency.isIgnored(x)).join(",");
const ignore = ignoredBy && `#(ignore:${ignoredBy})`;
const aliases = $i(PlatformType.values()).filter(x => dependency.getProjectId(x) !== dependency.id).map(x => `{${x}:${dependency.getProjectId(x)}}`).join("");
return `${dependency.id}${version}(${dependency.type})${aliases}${ignore}`;
}
/**
* Determines if the given value is a {@link Dependency}.
*
* @param dependency - The value to check.
*
* @returns A boolean indicating if the value is a {@link Dependency}.
*/
export function isDependency(dependency: unknown): dependency is Dependency {
const d = dependency as Dependency;
return (
typeof d?.id === "string" &&
typeof d.type === DependencyType.underlyingType &&
Array.isArray(d.versions) &&
typeof d.getProjectId === "function" &&
typeof d.isIgnored === "function"
);
}

View file

@ -0,0 +1,274 @@
import { DependencyType } from "@/dependencies/dependency-type";
import { PlatformType } from "@/platforms/platform-type";
import { parseVersionRange } from "@/utils/versioning/version-range";
import { createDependency, formatDependency, isDependency, parseDependency } from "@/dependencies/dependency";
describe("isDependency", () => {
test("returns true for Dependency-like objects", () => {
expect(isDependency(parseDependency("id"))).toBe(true);
});
test("returns false for non-Dependency-like objects", () => {
expect(isDependency({})).toBe(false);
});
test("returns false for null and undefined", () => {
expect(isDependency(null)).toBe(false);
expect(isDependency(undefined)).toBe(false);
});
});
describe("parseDependency", () => {
test("parses a fully-formed dependency string", () => {
const dependency = parseDependency("id@1.0.0-2.0.0-alpha.1(optional){modrinth:modrinth-slug}{curseforge:curseforge-slug}#(ignore:curseforge,github)");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["1.0.0-2.0.0-alpha.1"]);
expect(dependency.type).toBe(DependencyType.OPTIONAL);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("modrinth-slug");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("curseforge-slug");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(true);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(true);
});
test("parses a dependency string with omitted 'ignore' part", () => {
const dependency = parseDependency("id@1.0.0-2.0.0-alpha.1(optional){modrinth:modrinth-slug}{curseforge:curseforge-slug}");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["1.0.0-2.0.0-alpha.1"]);
expect(dependency.type).toBe(DependencyType.OPTIONAL);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("modrinth-slug");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("curseforge-slug");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(false);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(false);
});
test("parses a dependency string with omitted 'aliases' part", () => {
const dependency = parseDependency("id@1.0.0-2.0.0-alpha.1(optional)#(ignore:curseforge,github)");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["1.0.0-2.0.0-alpha.1"]);
expect(dependency.type).toBe(DependencyType.OPTIONAL);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("id");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("id");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(true);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(true);
});
test("parses a dependency string with omitted 'type' part", () => {
const dependency = parseDependency("id@1.0.0-2.0.0-alpha.1{modrinth:modrinth-slug}{curseforge:curseforge-slug}#(ignore:curseforge,github)");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["1.0.0-2.0.0-alpha.1"]);
expect(dependency.type).toBe(DependencyType.REQUIRED);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("modrinth-slug");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("curseforge-slug");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(true);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(true);
});
test("parses a dependency string with omitted 'version' part", () => {
const dependency = parseDependency("id(optional){modrinth:modrinth-slug}{curseforge:curseforge-slug}#(ignore:curseforge,github)");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["*"]);
expect(dependency.type).toBe(DependencyType.OPTIONAL);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("modrinth-slug");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("curseforge-slug");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(true);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(true);
});
test("parses a dependency string that only consists of id", () => {
const dependency = parseDependency("id");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["*"]);
expect(dependency.type).toBe(DependencyType.REQUIRED);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("id");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("id");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(false);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(false);
});
test("parses a dependency string that consists of id and version", () => {
const dependency = parseDependency("id@1.0.0");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["1.0.0"]);
expect(dependency.type).toBe(DependencyType.REQUIRED);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("id");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("id");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(false);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(false);
});
test("parses a dependency string that consists of id, version, and type", () => {
const dependency = parseDependency("id@1.0.0(optional)");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["1.0.0"]);
expect(dependency.type).toBe(DependencyType.OPTIONAL);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("id");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("id");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(false);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(false);
});
test("parses a dependency string that consists of id and type", () => {
const dependency = parseDependency("id(optional)");
expect(dependency).toBeDefined();
expect(dependency.id).toBe("id");
expect(dependency.versions).toEqual(["*"]);
expect(dependency.type).toBe(DependencyType.OPTIONAL);
expect(dependency.getProjectId(PlatformType.MODRINTH)).toBe("id");
expect(dependency.getProjectId(PlatformType.CURSEFORGE)).toBe("id");
expect(dependency.getProjectId(PlatformType.GITHUB)).toBe("id");
expect(dependency.isIgnored(PlatformType.MODRINTH)).toBe(false);
expect(dependency.isIgnored(PlatformType.CURSEFORGE)).toBe(false);
expect(dependency.isIgnored(PlatformType.GITHUB)).toBe(false);
});
test("dependency type parsing is case-insensitive", () => {
for (const type of DependencyType.values()) {
expect(parseDependency(`id(${type.toLowerCase()})`)?.type).toBe(type);
expect(parseDependency(`id(${type.toUpperCase()})`)?.type).toBe(type);
expect(parseDependency(`id(${DependencyType.friendlyNameOf(type)})`)?.type).toBe(type);
}
});
test("platform type parsing is case-insensitive", () => {
for (const platform of PlatformType.values()) {
expect(parseDependency(`id{${platform.toLowerCase()}:another-id}`)?.getProjectId(platform)).toBe("another-id");
expect(parseDependency(`id{${platform.toUpperCase()}:another-id}`)?.getProjectId(platform)).toBe("another-id");
expect(parseDependency(`id{${PlatformType.friendlyNameOf(platform)}:another-id}`)?.getProjectId(platform)).toBe("another-id");
expect(parseDependency(`id#(ignore:${platform.toLowerCase()})`)?.isIgnored(platform)).toBe(true);
expect(parseDependency(`id#(ignore:${platform.toUpperCase()})`)?.isIgnored(platform)).toBe(true);
expect(parseDependency(`id#(ignore:${PlatformType.friendlyNameOf(platform)})`)?.isIgnored(platform)).toBe(true);
}
});
test("returns undefined if the input string is null, undefined, or empty", () => {
expect(parseDependency(undefined)).toBeUndefined();
expect(parseDependency(null)).toBeUndefined();
expect(parseDependency("")).toBeUndefined();
});
});
describe("cerateDependency", () => {
test("parses dependency strings", () => {
expect(createDependency("id")).toBeDefined();
expect(createDependency("id@1.0.0")).toBeDefined();
expect(createDependency("id@1.0.0(optional)")).toBeDefined();
expect(createDependency("id(optional)")).toBeDefined();
expect(createDependency("id@1.0.0-2.0.0-alpha.1(optional){modrinth:modrinth-slug}{curseforge:curseforge-slug}#(ignore:curseforge,github)")).toBeDefined();
expect(createDependency("id@1.0.0-2.0.0-alpha.1(optional){modrinth:modrinth-slug}{curseforge:curseforge-slug}")).toBeDefined();
expect(createDependency("id@1.0.0-2.0.0-alpha.1(optional)#(ignore:curseforge,github)")).toBeDefined();
expect(createDependency("id@1.0.0-2.0.0-alpha.1{modrinth:modrinth-slug}{curseforge:curseforge-slug}#(ignore:curseforge,github)")).toBeDefined();
expect(createDependency("id(optional){modrinth:modrinth-slug}{curseforge:curseforge-slug}#(ignore:curseforge,github)")).toBeDefined();
});
test("converts DependencyInfo-like objects to Dependency objects", () => {
expect(createDependency({ id: "id" })?.id).toBe("id");
expect(createDependency({ id: "id", type: DependencyType.EMBEDDED })?.type).toBe(DependencyType.EMBEDDED);
expect(createDependency({ id: "id", versions: "1.0.0" })?.versions).toEqual(["1.0.0"]);
expect(createDependency({ id: "id", versions: ["1.0.0", "2.0.0"] })?.versions).toEqual(["1.0.0", "2.0.0"]);
expect(createDependency({ id: "id", versions: parseVersionRange("1.0.0") })?.versions).toEqual(["1.0.0"]);
expect(createDependency({ id: "id", ignore: true })?.isIgnored()).toBe(true);
expect(createDependency({ id: "id", ignoredPlatforms: [PlatformType.CURSEFORGE] })?.isIgnored(PlatformType.CURSEFORGE)).toBe(true);
expect(createDependency({ id: "id", aliases: [[PlatformType.CURSEFORGE, "curseforge-slug"]] })?.getProjectId(PlatformType.CURSEFORGE)).toBe("curseforge-slug");
});
test("returns Dependency-like objects as is", () => {
const dependency = parseDependency("id");
expect(createDependency(dependency)).toBe(dependency);
});
test("returns undefined if the input is null, undefined, or an empty string", () => {
expect(createDependency(null)).toBeUndefined();
expect(createDependency(undefined)).toBeUndefined();
expect(createDependency("")).toBeUndefined();
});
});
describe("formatDependency", () => {
test("formats fine-tuned dependencies as fully qualified dependency strings", () => {
const dependencies = [
"id@1.0.0-2.0.0-alpha.1(optional){curseforge:curseforge-slug}{modrinth:modrinth-slug}#(ignore:curseforge,github)",
"id@1.0.0(embedded){modrinth:modrinth-slug}#(ignore:curseforge)",
"id@1.0.0 || 2.0.0-alpha.1(conflicting){curseforge:curseforge-slug}#(ignore:github)",
];
for (const dependency of dependencies) {
expect(formatDependency(parseDependency(dependency))).toBe(dependency);
}
});
test("formats dependencies and omits unused 'ignore' section", () => {
const dependencies = [
"id@1.0.0-2.0.0-alpha.1(optional){curseforge:curseforge-slug}{modrinth:modrinth-slug}",
"id@1.0.0(embedded){modrinth:modrinth-slug}",
"id@1.0.0 || 2.0.0-alpha.1(conflicting){curseforge:curseforge-slug}",
];
for (const dependency of dependencies) {
expect(formatDependency(parseDependency(dependency))).toBe(dependency);
}
});
test("formats dependencies and omits unused 'aliases' section", () => {
const dependencies = [
"id@1.0.0-2.0.0-alpha.1(optional)#(ignore:curseforge,github)",
"id@1.0.0(embedded)#(ignore:curseforge)",
"id@1.0.0 || 2.0.0-alpha.1(conflicting)#(ignore:github)",
];
for (const dependency of dependencies) {
expect(formatDependency(parseDependency(dependency))).toBe(dependency);
}
});
test("formats dependencies and omits unused 'version' section", () => {
const dependencies = [
"id(optional){curseforge:curseforge-slug}{modrinth:modrinth-slug}#(ignore:curseforge,github)",
"id(embedded){modrinth:modrinth-slug}#(ignore:curseforge)",
"id(conflicting){curseforge:curseforge-slug}#(ignore:github)",
];
for (const dependency of dependencies) {
expect(formatDependency(parseDependency(dependency))).toBe(dependency);
}
});
test("returns an empty string for invalid dependencies", () => {
expect(formatDependency(null)).toBe("");
expect(formatDependency(undefined)).toBe("");
});
});