Newer
Older
dub_jkp / source / dub / recipe / selection.d
/**
 * Contains type definition for the selections file
 *
 * The selections file, commonly known by its file name
 * `dub.selections.json`, is used by Dub to store resolved
 * dependencies. Its purpose is identical to other package
 * managers' lock file.
 */
module dub.recipe.selection;

import dub.dependency;
import dub.internal.vibecompat.inet.path : NativePath;

import dub.internal.configy.Attributes;
import dub.internal.dyaml.stdsumtype;

import std.exception;

deprecated("Use either `Selections!1` or `SelectionsFile` instead")
public alias Selected = Selections!1;

/**
 * Top level type for `dub.selections.json`
 *
 * To support multiple version, we expose a `SumType` which
 * contains the "real" version being parsed.
 */
public struct SelectionsFile
{
    /// Private alias to avoid repetition
    private alias DataType = SumType!(Selections!0, Selections!1);

    /**
     * Get the `fileVersion` of this selection file
     *
     * The `fileVersion` is always present, no matter the version.
     * This is a convenience function that matches any version and allows
     * one to retrieve it.
     *
     * Note that the `fileVersion` can be an unsupported version.
     */
    public uint fileVersion () const @safe pure nothrow @nogc
    {
        return this.content.match!((s) => s.fileVersion);
    }

    /**
     * Whether this dub.selections.json can be inherited by nested projects
     * without local dub.selections.json
     */
    public bool inheritable () const @safe pure nothrow @nogc
    {
        return this.content.match!(
            (const Selections!0 _) => false,
            (const Selections!1 s) => s.inheritable,
        );
    }

    /**
     * The content of this selections file
     *
     * The underlying content can be accessed using
     * `dub.internal.yaml.stdsumtype : match`, for example:
     * ---
     * SelectionsFile file = readSelectionsFile();
     * file.content.match!(
     *     (Selections!0 s) => logWarn("Unsupported version: %s", s.fileVersion),
     *     (Selections!1 s) => logWarn("Old version (1), please upgrade!"),
     *     (Selections!2 s) => logInfo("You are up to date"),
     * );
     * ---
     */
    public DataType content;

    /**
     * Deserialize the selections file according to its version
     *
     * This will first deserialize the `fileVersion` only, and then
     * the expected version if it is supported. Unsupported versions
     * will be returned inside a `Selections!0` struct,
     * which only contains a `fileVersion`.
     */
    public static SelectionsFile fromYAML (scope ConfigParser!SelectionsFile parser)
    {
        import dub.internal.configy.Read;

        static struct OnlyVersion { uint fileVersion; }

        auto vers = parseConfig!OnlyVersion(
            CLIArgs.init, parser.node, StrictMode.Ignore);

        switch (vers.fileVersion) {
        case 1:
            return SelectionsFile(DataType(parser.parseAs!(Selections!1)));
        default:
            return SelectionsFile(DataType(Selections!0(vers.fileVersion)));
        }
    }
}

/**
 * A specific version of the selections file
 *
 * Currently, only two instantiations of this struct are possible:
 * - `Selections!0` is an invalid/unsupported version;
 * - `Selections!1` is the most widespread version;
 */
public struct Selections (ushort Version)
{
    ///
    public uint fileVersion = Version;

    static if (Version == 0) { /* Invalid version */ }
    else static if (Version == 1) {
        /// The selected package and their matching versions
        public SelectedDependency[string] versions;

        /// Whether this dub.selections.json can be inherited by nested projects
        /// without local dub.selections.json
        @Optional public bool inheritable;
    }
    else
        static assert(false, "This version is not supported");
}


/// Wrapper around `SelectedDependency` to do deserialization but still provide
/// a `Dependency` object to client code.
private struct SelectedDependency
{
    public Dependency actual;
    alias actual this;

    /// Constructor, used in `fromYAML`
    public this (inout(Dependency) dep) inout @safe pure nothrow @nogc
    {
        this.actual = dep;
    }

    /// Allow external code to assign to this object as if it was a `Dependency`
    public ref SelectedDependency opAssign (Dependency dep) return pure nothrow @nogc
    {
        this.actual = dep;
        return this;
    }

    /// Read a `Dependency` from the config file - Required to support both short and long form
    static SelectedDependency fromYAML (scope ConfigParser!SelectedDependency p)
    {
        import dub.internal.dyaml.node;

        if (p.node.nodeID == NodeID.scalar)
            return SelectedDependency(Dependency(Version(p.node.as!string)));

        auto d = p.parseAs!YAMLFormat;
        if (d.path.length)
            return SelectedDependency(Dependency(NativePath(d.path)));
        else
        {
            assert(d.version_.length);
            if (d.repository.length)
                return SelectedDependency(Dependency(Repository(d.repository, d.version_)));
            return SelectedDependency(Dependency(Version(d.version_)));
        }
    }

	/// In-file representation of a dependency as permitted in `dub.selections.json`
	private struct YAMLFormat
	{
		@Optional @Name("version") string version_;
		@Optional string path;
		@Optional string repository;

		public void validate () const scope @safe pure
		{
			enforce(this.version_.length || this.path.length || this.repository.length,
				"Need to provide a version string, or an object with one of the following fields: `version`, `path`, or `repository`");
			enforce(!this.path.length || !this.repository.length,
				"Cannot provide a `path` dependency if a repository dependency is used");
			enforce(!this.path.length || !this.version_.length,
				"Cannot provide a `path` dependency if a `version` dependency is used");
			enforce(!this.repository.length || this.version_.length,
				"Cannot provide a `repository` dependency without a `version`");
		}
	}
}

// Ensure we can read all type of dependencies
unittest
{
    import dub.internal.configy.Read : parseConfigString;

    immutable string content = `{
    "fileVersion": 1,
    "versions": {
        "simple": "1.5.6",
        "branch": "~master",
        "branch2": "~main",
        "path": { "path": "../some/where" },
        "repository": { "repository": "git+https://github.com/dlang/dub", "version": "123456123456123456" }
    }
}`;

    auto file = parseConfigString!SelectionsFile(content, "/dev/null");
    assert(file.fileVersion == 1);
    auto s = file.content.match!(
        (Selections!1 s) => s,
        (s) { assert(0); return Selections!(1).init; },
    );
    assert(!s.inheritable);
    assert(s.versions.length == 5);
    assert(s.versions["simple"]     == Dependency(Version("1.5.6")));
    assert(s.versions["branch"]     == Dependency(Version("~master")));
    assert(s.versions["branch2"]    == Dependency(Version("~main")));
    assert(s.versions["path"]       == Dependency(NativePath("../some/where")));
    assert(s.versions["repository"] == Dependency(Repository("git+https://github.com/dlang/dub", "123456123456123456")));
}

// with optional `inheritable` Boolean
unittest
{
    import dub.internal.configy.Read : parseConfigString;

    immutable string content = `{
    "fileVersion": 1,
    "inheritable": true,
    "versions": {
        "simple": "1.5.6",
    }
}`;

    auto s = parseConfigString!SelectionsFile(content, "/dev/null");
    assert(s.inheritable);
}

// Test reading an unsupported version
unittest
{
    import dub.internal.configy.Read : parseConfigString;

    immutable string content = `{"fileVersion": 9999, "thisis": "notrecognized"}`;
    auto s = parseConfigString!SelectionsFile(content, "/dev/null");
    assert(s.fileVersion == 9999);
}