diff --git a/source/dub/commandline.d b/source/dub/commandline.d index 38d4f98..628c1e0 100644 --- a/source/dub/commandline.d +++ b/source/dub/commandline.d @@ -107,7 +107,8 @@ new AddLocalCommand, new RemoveLocalCommand, new ListCommand, - new ListInstalledCommand + new ListInstalledCommand, + new SelectCommand ) ]; @@ -930,6 +931,51 @@ } } +/******************************************************************************/ +/* SELECT */ +/******************************************************************************/ + +class SelectCommand : Command { + private { + bool m_tuneup; + } + this() + { + /* + Development TODOs: + - DependencyGraph select needs to overrule resolution algorithm. + - Fail build, when selected version is not available + - dub update: updating to pinned by default or by flag? + + Done: + - write selected versions + - load selected versions + - init: warning if pinned dependency not found + */ + this.name = "select"; + this.argumentsPattern = ""; + this.description = "Stores the currently used dependent package in a file, this can be used later override the used versions."; + this.helpText = [ + "", + "", + "This stores the used package versions of the main package in the current working directory. The file is " ~ SelectedVersions.DefaultFile ~ " and this file can also be used to manually override only certain versions. (This includes switching to a local master )." + ]; + } + override void prepare(scope CommandArgs args) { + args.getopt("tuneup", &m_tuneup, [ + "Updates the project and performs a build. If successfull, rewrites the selected versions file ." + ]); + } + + override int execute(Dub dub, string[] free_args, string[] app_args) + { + logDiagnostic("loadPackageFromCwd"); + dub.loadPackageFromCwd(); + logDiagnostic("Selecting current versions:"); + dub.selectVersions(); + return 0; + } +} /******************************************************************************/ /* LIST */ diff --git a/source/dub/dependency.d b/source/dub/dependency.d index c880ced..4a58c30 100644 --- a/source/dub/dependency.d +++ b/source/dub/dependency.d @@ -37,32 +37,40 @@ struct Version { private { enum MAX_VERS = "99999.0.0"; + enum UNKNOWN_VERS = "unknown"; string m_version; } static @property RELEASE() { return Version("0.0.0"); } static @property HEAD() { return Version(MAX_VERS); } static @property MASTER() { return Version(MASTER_STRING); } + static @property UNKNOWN() { return Version(UNKNOWN_VERS); } static @property MASTER_STRING() { return "~master"; } static @property BRANCH_IDENT() { return '~'; } this(string vers) { enforce(vers.length > 1, "Version strings must not be empty."); - enforce(vers[0] == BRANCH_IDENT || vers.isValidVersion(), "Invalid SemVer format: "~vers); + if (vers[0] != BRANCH_IDENT && vers != UNKNOWN_VERS) + enforce(vers.isValidVersion(), "Invalid SemVer format: " ~ vers); m_version = vers; } - bool opEquals(ref const Version oth) const { return m_version == oth.m_version; } - bool opEquals(const Version oth) const { return m_version == oth.m_version; } + bool opEquals(const Version oth) const { + if (isUnknown || oth.isUnknown) { + throw new Exception("Can't compare unknown versions! (this: %s, other: %s)".format(this, oth)); + } + return m_version == oth.m_version; + } /// Returns true, if this version indicates a branch, which is not the trunk. - @property bool isBranch() const { return m_version[0] == BRANCH_IDENT; } + @property bool isBranch() const { return !m_version.empty && m_version[0] == BRANCH_IDENT; } @property bool isMaster() const { return m_version == MASTER_STRING; } @property bool isPreRelease() const { if (isBranch) return true; return isPreReleaseVersion(m_version); } + @property bool isUnknown() const { return m_version == UNKNOWN_VERS; } /** Comparing Versions is generally possible, but comparing Versions @@ -71,7 +79,10 @@ */ int opCmp(ref const Version other) const { - if(isBranch || other.isBranch) { + if (isUnknown || other.isUnknown) { + throw new Exception("Can't compare unknown versions! (this: %s, other: %s)".format(this, other)); + } + if (isBranch || other.isBranch) { if(m_version == other.m_version) return 0; else throw new Exception("Can't compare branch versions! (this: %s, other: %s)".format(this, other)); } @@ -130,6 +141,14 @@ for(int i=1; i=0; --j) assert(versions[j] < versions[i], "Failed: " ~ to!string(versions[j]) ~ "<" ~ to!string(versions[i])); + + a = Version.UNKNOWN; + b = Version.RELEASE; + assertThrown(a == b, "Failed: compared " ~ to!string(a) ~ " with " ~ to!string(b) ~ ""); + + a = Version.UNKNOWN; + b = Version.UNKNOWN; + assertThrown(a == b, "Failed: UNKNOWN == UNKNOWN"); } /** @@ -139,20 +158,29 @@ */ struct Dependency { private { + // Shortcut to create >=0.0.0 + enum ANY_IDENT = "*"; string m_cmpA; Version m_versA; string m_cmpB; Version m_versB; Path m_path; - string m_configuration = "library"; bool m_optional = false; } + // A Dependency, which matches every valid version. + static @property ANY() { return Dependency(ANY_IDENT); } + this(string ves) { enforce(ves.length > 0); string orig = ves; + if (ves == ANY_IDENT) { + // Any version is good. + ves = ">=0.0.0"; + } + if (ves[0] == Version.BRANCH_IDENT && ves[1] == '>') { // Shortcut: "~>x.y.z" variant. Last non-zero number will indicate // the base for this so something like this: ">=x.y.z =" && m_cmpB == "<=" ){ - if( m_versA == Version.MASTER ) r = "~master"; + // Special "==" case + if (m_versA == Version.MASTER ) r = "~master"; else r = to!string(m_versA); } else { if( m_versA != Version.RELEASE ) r = m_cmpA ~ to!string(m_versA); if( m_versB != Version.HEAD ) r ~= (r.length==0?"" : " ") ~ m_cmpB ~ to!string(m_versB); if( m_versA == Version.RELEASE && m_versB == Version.HEAD ) r = ">=0.0.0"; } - // TODO(mdondorff): add information to path and optionality. return r; } + Json toJson() const { + Json json; + if( path.empty && !optional ){ + json = Json(versionString()); + } else { + json = Json.emptyObject; + json["version"] = versionString(); + if (!path.empty) json["path"] = path.toString(); + if (optional) json["optional"] = true; + } + return json; + } + + unittest { + Dependency d = Dependency("==1.0.0"); + assert(d.toJson() == Json("1.0.0"), "Failed: " ~ d.toJson().toPrettyString()); + // TODO: The previous will fail with + // d = fromJson((fromJson(d.toJson())).toJson()); + // because + // Dependency("==1.0.0").versionString -> "1.0.0" and + // Dependency("1.0.0").versionString -> ">=1.0.0" + // Also, this fails + // assert(Dependency(Version("1.0.0")) == Dependency("1.0.0")); + } + + static Dependency fromJson(Json verspec) { + Dependency dep; + if( verspec.type == Json.Type.object ){ + enforce("version" in verspec, "No version field specified!"); + auto ver = verspec["version"].get!string; + if( auto pp = "path" in verspec ) { + // This enforces the "version" specifier to be a simple version, + // without additional range specifiers. + dep = Dependency(Version(ver)); + dep.path = Path(verspec.path.get!string()); + } else { + // Using the string to be able to specifiy a range of versions. + dep = Dependency(ver); + } + if( auto po = "optional" in verspec ) { + dep.optional = verspec.optional.get!bool(); + } + } else { + // canonical "package-id": "version" + dep = Dependency(verspec.get!string()); + } + return dep; + } + + unittest { + assert(fromJson(parseJsonString("\">=1.0.0 <2.0.0\"")) == Dependency(">=1.0.0 <2.0.0")); + Dependency parsed = fromJson(parseJsonString(` + { + "version": "2.0.0", + "optional": true, + "path": "path/to/package" + } + `)); + Dependency d = Dependency(Version("2.0.0")); + d.optional = true; + d.path = Path("path/to/package"); + assert(d == parsed); + // optional and path not checked by opEquals. + assert(d.optional == parsed.optional); + assert(d.path == parsed.path); + } + bool opEquals(in Dependency o) const { // TODO(mdondorff): Check if not comparing the path is correct for all clients. return o.m_cmpA == m_cmpA && o.m_cmpB == m_cmpB && o.m_versA == m_versA && o.m_versB == m_versB - && o.m_configuration == m_configuration && o.m_optional == m_optional; } @@ -271,8 +377,6 @@ const { if (!valid()) return this; if (!o.valid()) return o; - if (m_configuration != o.m_configuration) - return Dependency(">=1.0.0 <=0.0.0"); enforce(m_versA.isBranch == o.m_versA.isBranch, format("Conflicting versions: %s vs. %s", m_versA, o.m_versA)); enforce(m_versB.isBranch == o.m_versB.isBranch, format("Conflicting versions: %s vs. %s", m_versB, o.m_versB)); @@ -441,6 +545,14 @@ assert(a.valid); assert(a.version_ == Version("~d2test")); + a = Dependency.ANY; + assert(!a.optional); + assert(a.valid); + assertThrown(a.version_); + b = Dependency(">=1.0.1"); + assert(b == a.merge(b)); + assert(b == b.merge(a)); + logDebug("Dependency Unittest sucess."); } diff --git a/source/dub/dub.d b/source/dub/dub.d index e904398..f0c58ec 100644 --- a/source/dub/dub.d +++ b/source/dub/dub.d @@ -192,6 +192,19 @@ } } + /// AKA "Pinning" or "shrinkwrap" + void selectVersions() { + enforce(m_rootPath == m_projectPath, "Currently, DUB can only select versions directly from the main project's working directory."); + SelectedVersions selectedVersions = new SelectedVersions; + Action[] allActions = m_project.determineActions(m_packageSuppliers, UpdateOptions.none, selectedVersions); + if (allActions.length > 0) { + logError("Cannot select build versions, there are missing updates to be performed."); + throw new Exception("Version selection failed."); + } + selectedVersions.save(m_rootPath ~ Path(SelectedVersions.DefaultFile)); + logInfo("Stored currently selected versions into: " ~ SelectedVersions.DefaultFile); + } + /// Generate project files for a specified IDE. /// Any existing project files will be overridden. void generateProject(string ide, GeneratorSettings settings) { diff --git a/source/dub/package_.d b/source/dub/package_.d index 616d4da..a765190 100644 --- a/source/dub/package_.d +++ b/source/dub/package_.d @@ -134,7 +134,9 @@ if (m_info.version_.length == 0) { logDiagnostic("Note: Failed to determine version of package %s at %s. Assuming ~master.", m_info.name, this.path.toNativeString()); - m_info.version_ = "~master"; + // TODO: Assume unknown version here? + // m_info.version_ = Version.UNKNOWN.toString(); + m_info.version_ = Version.MASTER.toString(); } else logDiagnostic("Determined package version using GIT: %s %s", m_info.name, m_info.version_); } } @@ -213,6 +215,7 @@ */ void storeInfo() { + enforce(!ver.isUnknown, "Trying to store a package with an 'unknown' version, this is not supported."); auto filename = m_path ~ defaultPackageFilename(); auto dstFile = openFile(filename.toNativeString(), FileMode.CreateTrunc); scope(exit) dstFile.close(); @@ -630,27 +633,7 @@ case "dependencies": foreach( string pkg, verspec; value ) { enforce(pkg !in this.dependencies, "The dependency '"~pkg~"' is specified more than once." ); - Dependency dep; - if( verspec.type == Json.Type.object ){ - enforce("version" in verspec, "Package information provided for package " ~ pkg ~ " is missing a version field."); - auto ver = verspec["version"].get!string; - if( auto pp = "path" in verspec ) { - // This enforces the "version" specifier to be a simple version, - // without additional range specifiers. - dep = Dependency(Version(ver)); - dep.path = Path(verspec.path.get!string()); - } else { - // Using the string to be able to specifiy a range of versions. - dep = Dependency(ver); - } - if( auto po = "optional" in verspec ) { - dep.optional = verspec.optional.get!bool(); - } - } else { - // canonical "package-id": "version" - dep = Dependency(verspec.get!string()); - } - this.dependencies[pkg] = dep; + this.dependencies[pkg] = deserializeJson!Dependency(verspec); } break; case "targetType": @@ -716,17 +699,8 @@ auto ret = Json.emptyObject; if( this.dependencies !is null ){ auto deps = Json.emptyObject; - foreach( pack, d; this.dependencies ){ - if( d.path.empty && !d.optional ){ - deps[pack] = d.toString(); - } else { - auto vjson = Json.emptyObject; - vjson["version"] = d.toString(); - if (!d.path.empty) vjson["path"] = d.path.toString(); - if (d.optional) vjson["optional"] = true; - deps[pack] = vjson; - } - } + foreach( pack, d; this.dependencies ) + deps[pack] = serializeToJson(d); ret.dependencies = deps; } if (targetType != TargetType.autodetect) ret["targetType"] = targetType.to!string(); diff --git a/source/dub/project.d b/source/dub/project.d index 86f7146..1c2925b 100644 --- a/source/dub/project.d +++ b/source/dub/project.d @@ -44,6 +44,7 @@ //Package[string] m_packages; Package[] m_dependencies; Package[][Package] m_dependees; + SelectedVersions m_selectedVersions = new SelectedVersions(); } this(PackageManager package_manager, Path project_path) @@ -162,6 +163,9 @@ try m_json = jsonFromFile(m_root ~ ".dub/dub.json", true); catch(Exception t) logDiagnostic("Failed to read .dub/dub.json: %s", t.msg); + try m_selectedVersions = new SelectedVersions(m_root ~ SelectedVersions.DefaultFile); + catch(Exception e) logDiagnostic("A " ~ SelectedVersions.DefaultFile ~ " file was not found or failed to load:\n%s", e.msg); + // load package description if (!m_fixedPackage) { if (!Package.isPackageAt(m_root)) { @@ -188,7 +192,13 @@ void collectDependenciesRec(Package pack) { logDiagnostic("Collecting dependencies for %s", pack.name); - foreach( name, vspec; pack.dependencies ){ + foreach( name, original_vspec; pack.dependencies ){ + Dependency vspec = original_vspec; + if (m_selectedVersions.hasSelectedVersion(name)) { + vspec = m_selectedVersions.selectedVersion(name); + logDiagnostic("Dependency on %s overruled by locally selected version: %s", name, vspec); + } + Package p; if( !vspec.path.empty ){ Path path = vspec.path; @@ -206,6 +216,11 @@ collectDependenciesRec(p); } } + + if( m_selectedVersions.hasSelectedVersion(name) && !pack) { + logError("The locally selected version was not found: " ~ name); + } + m_dependees[p] ~= pack; //enforce(p !is null, "Failed to resolve dependency "~name~" "~vspec.toString()); } @@ -454,9 +469,9 @@ return false; } - /// Actions which can be performed to update the application. - Action[] determineActions(PackageSupplier[] packageSuppliers, UpdateOptions option) + /// selectedVersions: + Action[] determineActions(PackageSupplier[] packageSuppliers, UpdateOptions option, SelectedVersions selectedVersions = null) { scope(exit) writeDubJson(); @@ -511,6 +526,7 @@ actions ~= act; } int[string] upgradePackages; + scope(failure) if (selectedVersions !is null) selectedVersions.clean(); foreach( string pkg, d; graph.needed() ) { auto basepkg = pkg.getBasePackage(); auto p = basepkg in retrieved; @@ -531,6 +547,8 @@ } else { logDiagnostic("Required package '"~basepkg~"' found with version '"~p.vers~"'"); + if (selectedVersions !is null) + selectedVersions.selectVersion(pkg, p.ver, d.packages); } } } @@ -892,3 +910,68 @@ ret.put(isIdentChar(ch) ? ch : '_'); return ret.data; } + +class SelectedVersions { + static @property DefaultFile() { return "dub.select.json"; } + + this() { } + + this(Path path) { + auto json = jsonFromFile(path); + deserialize(json); + } + + void clean() { + Selected[string] empty; + m_selectedVersions = empty; + } + + void selectVersion(string packageId, Version version_, Dependency[string] issuer) { + enforce(packageId !in m_selectedVersions, "Cannot reselect a package!"); + m_selectedVersions[packageId] = Selected(Dependency(version_), issuer); + } + + bool hasSelectedVersion(string packageId) const { + return (packageId in m_selectedVersions) !is null; + } + + Dependency selectedVersion(string packageId) const { + enforce(hasSelectedVersion(packageId)); + return m_selectedVersions[packageId].versionSpec; + } + + void save(Path path) const { + Json json = serialize(); + auto file = openFile(path, FileMode.CreateTrunc); + scope(exit) file.close(); + file.writePrettyJsonString(json); + } + + private struct Selected { + this(Dependency versSpec_, Dependency[string] packages_) { + versionSpec = versSpec_; + packages = packages_; + } + Dependency versionSpec; + Dependency[string] packages; + } + private { + enum FileVersion = 1; + Selected[string] m_selectedVersions; + } + + private Json serialize() const { + Json json = serializeToJson(m_selectedVersions); + Json serialized = Json.emptyObject; + serialized.fileVersion = FileVersion; + serialized.versions = json; + return serialized; + } + + private void deserialize(Json json) { + enforce(cast(int)json["fileVersion"] == FileVersion, "Mismatched dub.select.json version: " ~ to!string(cast(int)json["fileVersion"]) ~ "vs. " ~to!string(FileVersion)); + clean(); + scope(failure) clean(); + deserializeJson(m_selectedVersions, json.versions); + } +}