Newer
Older
dub_jkp / source / dub / package_.d
/**
	Stuff with dependencies.

	Copyright: © 2012-2013 Matthias Dondorff
	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
	Authors: Matthias Dondorff
*/
module dub.package_;

public import dub.recipe.packagerecipe;

import dub.compilers.compiler;
import dub.dependency;
import dub.recipe.json;
import dub.recipe.sdl;

import dub.internal.utils;
import dub.internal.vibecompat.core.log;
import dub.internal.vibecompat.core.file;
import dub.internal.vibecompat.data.json;
import dub.internal.vibecompat.inet.url;

import std.algorithm;
import std.array;
import std.conv;
import std.exception;
import std.file;
import std.range;
import std.string;
import std.traits : EnumMembers;



enum PackageFormat { json, sdl }
struct FilenameAndFormat
{
	string filename;
	PackageFormat format;
}
struct PathAndFormat
{
	Path path;
	PackageFormat format;
	@property bool empty() { return path.empty; }
	string toString() { return path.toString(); }
}

// Supported package descriptions in decreasing order of preference.
enum FilenameAndFormat[] packageInfoFiles = [
	{"dub.json", PackageFormat.json},
	/*{"dub.sdl",PackageFormat.sdl},*/
	{"package.json", PackageFormat.json}
];

string defaultPackageFilename() {
	return packageInfoFiles[0].filename;
}

/**
	Represents a package, including its sub packages

	Documentation of the dub.json can be found at
	http://registry.vibed.org/package-format
*/
class Package {
	static struct LocalPackageDef { string name; Version version_; Path path; }

	private {
		Path m_path;
		PathAndFormat m_infoFile;
		PackageRecipe m_info;
		Package m_parentPackage;
	}

	static PathAndFormat findPackageFile(Path path)
	{
		foreach(file; packageInfoFiles) {
			auto filename = path ~ file.filename;
			if(existsFile(filename)) return PathAndFormat(filename, file.format);
		}
		return PathAndFormat(Path());
	}

	this(Path root, PathAndFormat infoFile = PathAndFormat(), Package parent = null, string versionOverride = "")
	{
		RawPackage raw_package;
		m_infoFile = infoFile;

		try {
			if(m_infoFile.empty) {
				m_infoFile = findPackageFile(root);
				if(m_infoFile.empty) throw new Exception("no package file was found, expected one of the following: "~to!string(packageInfoFiles));
			}
			raw_package = rawPackageFromFile(m_infoFile);
		} catch (Exception ex) throw ex;//throw new Exception(format("Failed to load package %s: %s", m_infoFile.toNativeString(), ex.msg));

		enforce(raw_package !is null, format("Missing package description for package at %s", root.toNativeString()));
		this(raw_package, root, parent, versionOverride);
	}

	this(Json package_info, Path root = Path(), Package parent = null, string versionOverride = "")
	{
		this(new JsonPackage(package_info), root, parent, versionOverride);
	}

	this(const RawPackage raw_package, Path root = Path(), Package parent = null, string versionOverride = "")
	{
		PackageRecipe recipe;

		// parse the Package description
		if(raw_package !is null)
		{
			scope(failure) logError("Failed to parse package description in %s", root.toNativeString());
			raw_package.parseInto(recipe, parent ? parent.name : null);

			if (!versionOverride.empty)
				recipe.version_ = versionOverride;

			// try to run git to determine the version of the package if no explicit version was given
			if (recipe.version_.length == 0 && !parent) {
				try recipe.version_ = determineVersionFromSCM(root);
				catch (Exception e) logDebug("Failed to determine version by SCM: %s", e.msg);

				if (recipe.version_.length == 0) {
					logDiagnostic("Note: Failed to determine version of package %s at %s. Assuming ~master.", recipe.name, this.path.toNativeString());
					// TODO: Assume unknown version here?
					// recipe.version_ = Version.UNKNOWN.toString();
					recipe.version_ = Version.MASTER.toString();
				} else logDiagnostic("Determined package version using GIT: %s %s", recipe.name, recipe.version_);
			}
		}

		this(recipe, root, parent);
	}

	this(PackageRecipe recipe, Path root = Path(), Package parent = null)
	{
		m_parentPackage = parent;
		m_path = root;
		m_path.endsWithSlash = true;

		// check for default string import folders
		foreach(defvf; ["views"]){
			auto p = m_path ~ defvf;
			if( existsFile(p) )
				m_info.buildSettings.stringImportPaths[""] ~= defvf;
		}

		// use the given recipe as the basis
		m_info = recipe;

		// WARNING: changed semantics here. Previously, "sourcePaths" etc.
		// could overwrite what was determined here. Now the default paths
		// are always added. This must be fixed somehow!

		// check for default source folders
		string app_main_file;
		auto pkg_name = recipe.name.length ? recipe.name : "unknown";
		foreach(defsf; ["source/", "src/"]){
			auto p = m_path ~ defsf;
			if( existsFile(p) ){
				m_info.buildSettings.sourcePaths[""] ~= defsf;
				m_info.buildSettings.importPaths[""] ~= defsf;
				foreach (fil; ["app.d", "main.d", pkg_name ~ "/main.d", pkg_name ~ "/" ~ "app.d"])
					if (existsFile(p ~ fil)) {
						app_main_file = Path(defsf ~ fil).toNativeString();
						break;
					}
			}
		}

		// generate default configurations if none are defined
		if (m_info.configurations.length == 0) {
			if (m_info.buildSettings.targetType == TargetType.executable) {
				BuildSettingsTemplate app_settings;
				app_settings.targetType = TargetType.executable;
				if (m_info.buildSettings.mainSourceFile.empty) app_settings.mainSourceFile = app_main_file;
				m_info.configurations ~= ConfigurationInfo("application", app_settings);
			} else if (m_info.buildSettings.targetType != TargetType.none) {
				BuildSettingsTemplate lib_settings;
				lib_settings.targetType = m_info.buildSettings.targetType == TargetType.autodetect ? TargetType.library : m_info.buildSettings.targetType;

				if (m_info.buildSettings.targetType == TargetType.autodetect) {
					if (app_main_file.length) {
						lib_settings.excludedSourceFiles[""] ~= app_main_file;

						BuildSettingsTemplate app_settings;
						app_settings.targetType = TargetType.executable;
						app_settings.mainSourceFile = app_main_file;
						m_info.configurations ~= ConfigurationInfo("application", app_settings);
					}
				}

				m_info.configurations ~= ConfigurationInfo("library", lib_settings);
			}
		}
		simpleLint();
	}

	@property string name()
	const {
		if (m_parentPackage) return m_parentPackage.name ~ ":" ~ m_info.name;
		else return m_info.name;
	}
	@property string vers() const { return m_parentPackage ? m_parentPackage.vers : m_info.version_; }
	@property Version ver() const { return Version(this.vers); }
	@property void ver(Version ver) { assert(m_parentPackage is null); m_info.version_ = ver.toString(); }
	@property ref inout(PackageRecipe) info() inout { return m_info; }
	@property Path path() const { return m_path; }
	@property Path packageInfoFilename() const { return m_infoFile.path; }
	@property const(Dependency[string]) dependencies() const { return m_info.dependencies; }
	@property inout(Package) basePackage() inout { return m_parentPackage ? m_parentPackage.basePackage : this; }
	@property inout(Package) parentPackage() inout { return m_parentPackage; }
	@property inout(SubPackage)[] subPackages() inout { return m_info.subPackages; }

	@property string[] configurations()
	const {
		auto ret = appender!(string[])();
		foreach( ref config; m_info.configurations )
			ret.put(config.name);
		return ret.data;
	}

	const(Dependency[string]) getDependencies(string config)
	const {
		Dependency[string] ret;
		foreach (k, v; m_info.buildSettings.dependencies)
			ret[k] = v;
		foreach (ref conf; m_info.configurations)
			if (conf.name == config) {
				foreach (k, v; conf.buildSettings.dependencies)
					ret[k] = v;
				break;
			}
		return ret;
	}

	/** Overwrites the packge description file using the default filename with the current information.
	*/
	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();
		dstFile.writePrettyJsonString(m_info.toJson());
		m_infoFile = PathAndFormat(filename);
	}

	/*inout(Package) getSubPackage(string name, bool silent_fail = false)
	inout {
		foreach (p; m_info.subPackages)
			if (p.package_ !is null && p.package_.name == this.name ~ ":" ~ name)
				return p.package_;
		enforce(silent_fail, format("Unknown sub package: %s:%s", this.name, name));
		return null;
	}*/

	void warnOnSpecialCompilerFlags()
	{
		// warn about use of special flags
		m_info.buildSettings.warnOnSpecialCompilerFlags(m_info.name, null);
		foreach (ref config; m_info.configurations)
			config.buildSettings.warnOnSpecialCompilerFlags(m_info.name, config.name);
	}

	const(BuildSettingsTemplate) getBuildSettings(string config = null)
	const {
		if (config.length) {
			foreach (ref conf; m_info.configurations)
				if (conf.name == config)
					return conf.buildSettings;
			assert(false, "Unknown configuration: "~config);
		} else {
			return m_info.buildSettings;
		}
	}

	/// Returns all BuildSettings for the given platform and config.
	BuildSettings getBuildSettings(in BuildPlatform platform, string config)
	const {
		BuildSettings ret;
		m_info.buildSettings.getPlatformSettings(ret, platform, this.path);
		bool found = false;
		foreach(ref conf; m_info.configurations){
			if( conf.name != config ) continue;
			conf.buildSettings.getPlatformSettings(ret, platform, this.path);
			found = true;
			break;
		}
		assert(found || config is null, "Unknown configuration for "~m_info.name~": "~config);

		// construct default target name based on package name
		if( ret.targetName.empty ) ret.targetName = this.name.replace(":", "_");

		// special support for DMD style flags
		getCompiler("dmd").extractBuildOptions(ret);

		return ret;
	}

	/// Returns the combination of all build settings for all configurations and platforms
	BuildSettings getCombinedBuildSettings()
	const {
		BuildSettings ret;
		m_info.buildSettings.getPlatformSettings(ret, BuildPlatform.any, this.path);
		foreach(ref conf; m_info.configurations)
			conf.buildSettings.getPlatformSettings(ret, BuildPlatform.any, this.path);

		// construct default target name based on package name
		if (ret.targetName.empty) ret.targetName = this.name.replace(":", "_");

		// special support for DMD style flags
		getCompiler("dmd").extractBuildOptions(ret);

		return ret;
	}

	void addBuildTypeSettings(ref BuildSettings settings, in BuildPlatform platform, string build_type)
	const {
		if (build_type == "$DFLAGS") {
			import std.process;
			string dflags = environment.get("DFLAGS");
			settings.addDFlags(dflags.split());
			return;
		}

		if (auto pbt = build_type in m_info.buildTypes) {
			logDiagnostic("Using custom build type '%s'.", build_type);
			pbt.getPlatformSettings(settings, platform, this.path);
		} else {
			with(BuildOptions) switch (build_type) {
				default: throw new Exception(format("Unknown build type for %s: '%s'", this.name, build_type));
				case "plain": break;
				case "debug": settings.addOptions(debugMode, debugInfo); break;
				case "release": settings.addOptions(releaseMode, optimize, inline); break;
				case "release-nobounds": settings.addOptions(releaseMode, optimize, inline, noBoundsCheck); break;
				case "unittest": settings.addOptions(unittests, debugMode, debugInfo); break;
				case "docs": settings.addOptions(syntaxOnly); settings.addDFlags("-c", "-Dddocs"); break;
				case "ddox": settings.addOptions(syntaxOnly); settings.addDFlags("-c", "-Df__dummy.html", "-Xfdocs.json"); break;
				case "profile": settings.addOptions(profile, optimize, inline, debugInfo); break;
				case "cov": settings.addOptions(coverage, debugInfo); break;
				case "unittest-cov": settings.addOptions(unittests, coverage, debugMode, debugInfo); break;
			}
		}
	}

	string getSubConfiguration(string config, in Package dependency, in BuildPlatform platform)
	const {
		bool found = false;
		foreach(ref c; m_info.configurations){
			if( c.name == config ){
				if( auto pv = dependency.name in c.buildSettings.subConfigurations ) return *pv;
				found = true;
				break;
			}
		}
		assert(found || config is null, "Invalid configuration \""~config~"\" for "~this.name);
		if( auto pv = dependency.name in m_info.buildSettings.subConfigurations ) return *pv;
		return null;
	}

	/// Returns the default configuration to build for the given platform
	string getDefaultConfiguration(in BuildPlatform platform, bool allow_non_library = false)
	const {
		foreach (ref conf; m_info.configurations) {
			if (!conf.matchesPlatform(platform)) continue;
			if (!allow_non_library && conf.buildSettings.targetType == TargetType.executable) continue;
			return conf.name;
		}
		return null;
	}

	/// Returns a list of configurations suitable for the given platform
	string[] getPlatformConfigurations(in BuildPlatform platform, bool is_main_package = false)
	const {
		auto ret = appender!(string[]);
		foreach(ref conf; m_info.configurations){
			if (!conf.matchesPlatform(platform)) continue;
			if (!is_main_package && conf.buildSettings.targetType == TargetType.executable) continue;
			ret ~= conf.name;
		}
		if (ret.data.length == 0) ret.put(null);
		return ret.data;
	}

	/// Human readable information of this package and its dependencies.
	string generateInfoString() const {
		string s;
		s ~= m_info.name ~ ", version '" ~ m_info.version_ ~ "'";
		s ~= "\n  Dependencies:";
		foreach(string p, ref const Dependency v; m_info.dependencies)
			s ~= "\n    " ~ p ~ ", version '" ~ v.toString() ~ "'";
		return s;
	}

	bool hasDependency(string depname, string config)
	const {
		if (depname in m_info.buildSettings.dependencies) return true;
		foreach (ref c; m_info.configurations)
			if ((config.empty || c.name == config) && depname in c.buildSettings.dependencies)
				return true;
		return false;
	}

	void describe(ref Json dst, BuildPlatform platform, string config)
	{
		dst.path = m_path.toNativeString();
		dst.name = this.name;
		dst["version"] = this.vers;
		dst.description = m_info.description;
		dst.homepage = m_info.homepage;
		dst.authors = m_info.authors.serializeToJson();
		dst.copyright = m_info.copyright;
		dst.license = m_info.license;
		dst.dependencies = m_info.dependencies.keys.serializeToJson();

		// save build settings
		BuildSettings bs = getBuildSettings(platform, config);
		BuildSettings allbs = getCombinedBuildSettings();

		foreach (string k, v; bs.serializeToJson()) dst[k] = v;
		dst.remove("requirements");
		dst.remove("sourceFiles");
		dst.remove("importFiles");
		dst.remove("stringImportFiles");
		dst.targetType = bs.targetType.to!string();
		if (dst.targetType != TargetType.none)
			dst.targetFileName = getTargetFileName(bs, platform);

		// prettify build requirements output
		Json[] breqs;
		for (int i = 1; i <= BuildRequirements.max; i <<= 1)
			if (bs.requirements & i)
				breqs ~= Json(to!string(cast(BuildRequirements)i));
		dst.buildRequirements = breqs;

		// prettify options output
		Json[] bopts;
		for (int i = 1; i <= BuildOptions.max; i <<= 1)
			if (bs.options & i)
				bopts ~= Json(to!string(cast(BuildOptions)i));
		dst.options = bopts;

		// collect all possible source files and determine their types
		string[string] sourceFileTypes;
		foreach (f; allbs.stringImportFiles) sourceFileTypes[f] = "unusedStringImport";
		foreach (f; allbs.importFiles) sourceFileTypes[f] = "unusedImport";
		foreach (f; allbs.sourceFiles) sourceFileTypes[f] = "unusedSource";
		foreach (f; bs.stringImportFiles) sourceFileTypes[f] = "stringImport";
		foreach (f; bs.importFiles) sourceFileTypes[f] = "import";
		foreach (f; bs.sourceFiles) sourceFileTypes[f] = "source";
		Json[] files;
		foreach (f; sourceFileTypes.byKey.array.sort) {
			auto jf = Json.emptyObject;
			jf["path"] = f;
			jf["type"] = sourceFileTypes[f];
			files ~= jf;
		}
		dst.files = Json(files);
	}

	private void simpleLint() const {
		if (m_parentPackage) {
			if (m_parentPackage.path != path) {
				if (info.license.length && info.license != m_parentPackage.info.license)
					logWarn("License in subpackage %s is different than it's parent package, this is discouraged.", name);
			}
		}
		if (name.empty()) logWarn("The package in %s has no name.", path);
	}

	private static RawPackage rawPackageFromFile(PathAndFormat file, bool silent_fail = false) {
		if( silent_fail && !existsFile(file.path) ) return null;
		auto f = openFile(file.path.toNativeString(), FileMode.Read);
		scope(exit) f.close();
		auto text = stripUTF8Bom(cast(string)f.readAll());

		final switch(file.format) {
			case PackageFormat.json:
				return new JsonPackage(parseJsonString(text));
			case PackageFormat.sdl:
				if(silent_fail) return null; throw new Exception("SDL not implemented");
		}
	}

	static abstract class RawPackage
	{
		string package_name; // Should already be lower case
		string version_;
		abstract void parseInto(ref PackageRecipe package_, string parent_name) const;
	}
	private static class JsonPackage : RawPackage
	{
		Json json;
		this(Json json) {
			this.json = json;

			string nameLower;
			if(json.type == Json.Type.string) {
				nameLower = json.get!string.toLower();
				this.json = nameLower;
			} else {
				nameLower = json.name.get!string.toLower();
				this.json.name = nameLower;
				this.package_name = nameLower;

				Json versionJson = json["version"];
				this.version_ = (versionJson.type == Json.Type.undefined) ? null : versionJson.get!string;
			}

			this.package_name = nameLower;
		}
		override void parseInto(ref PackageRecipe recipe, string parent_name) const
		{
			recipe.parseJson(json, parent_name);
		}
	}
	private static class SdlPackage : RawPackage
	{
		override void parseInto(ref PackageRecipe package_, string parent_name) const
		{
			throw new Exception("SDL packages not implemented yet");
		}
	}
}


private string determineVersionFromSCM(Path path)
{
	import std.process;
	import dub.semver;

	auto git_dir = path ~ ".git";
	if (!existsFile(git_dir) || !isDir(git_dir.toNativeString)) return null;
	auto git_dir_param = "--git-dir=" ~ git_dir.toNativeString();

	static string exec(scope string[] params...) {
		auto ret = executeShell(escapeShellCommand(params));
		if (ret.status == 0) return ret.output.strip;
		logDebug("'%s' failed with exit code %s: %s", params.join(" "), ret.status, ret.output.strip);
		return null;
	}

	if (auto tag = exec("git", git_dir_param, "describe", "--long", "--tags")) {
		auto parts = tag.split("-");
		auto commit = parts[$-1];
		auto num = parts[$-2].to!int;
		tag = parts[0 .. $-2].join("-");
		if (tag.startsWith("v") && isValidVersion(tag[1 .. $])) {
			if (num == 0) return tag[1 .. $];
			else if (tag.canFind("+")) return format("%s.commit.%s.%s", tag[1 .. $], num, commit);
			else return format("%s+commit.%s.%s", tag[1 .. $], num, commit);
		}
	}

	if (auto branch = exec("git", git_dir_param, "rev-parse", "--abbrev-ref", "HEAD")) {
		if (branch != "HEAD") return "~" ~ branch;
	}

	return null;
}