diff --git a/build-files.txt b/build-files.txt index 6075c56c22..6916676e03 100644 --- a/build-files.txt +++ b/build-files.txt @@ -93,5 +93,6 @@ source/dub/recipe/json.d source/dub/recipe/packagerecipe.d source/dub/recipe/selection.d source/dub/recipe/sdl.d +source/dub/recipe/yaml.d source/dub/semver.d source/dub/version_.d diff --git a/source/dub/commandline.d b/source/dub/commandline.d index dc2bf49027..c1b512668c 100644 --- a/source/dub/commandline.d +++ b/source/dub/commandline.d @@ -1220,7 +1220,7 @@ class InitCommand : Command { if (m_nonInteractive) return; enum free_choice = true; - fmt = select("a package recipe format", !free_choice, fmt.to!string, "sdl", "json").to!PackageFormat; + fmt = select("a package recipe format", !free_choice, fmt.to!string, "sdl", "json", "yaml").to!PackageFormat; auto author = p.authors.join(", "); while (true) { // Tries getting the name until a valid one is given. @@ -2961,7 +2961,7 @@ class ConvertCommand : Command { override void prepare(scope CommandArgs args) { - args.getopt("f|format", &m_format, ["Specifies the target package recipe format. Possible values:", " json, sdl"]); + args.getopt("f|format", &m_format, ["Specifies the target package recipe format. Possible values:", " json, sdl, yaml"]); args.getopt("s|stdout", &m_stdout, ["Outputs the converted package recipe to stdout instead of writing to disk."]); } diff --git a/source/dub/dub.d b/source/dub/dub.d index d20baafecc..4bfc395868 100644 --- a/source/dub/dub.d +++ b/source/dub/dub.d @@ -1476,7 +1476,7 @@ class Dub { Params: destination_file_ext = The file extension matching the desired - format. Possible values are "json" or "sdl". + format. Possible values are "json", "sdl", or "yaml". print_only = Print the converted recipe instead of writing to disk */ void convertRecipe(string destination_file_ext, bool print_only = false) diff --git a/source/dub/package_.d b/source/dub/package_.d index 38568c85e5..61e3ae84fc 100644 --- a/source/dub/package_.d +++ b/source/dub/package_.d @@ -35,7 +35,8 @@ import std.typecons : Nullable; /// Lists the supported package recipe formats. enum PackageFormat { json, /// JSON based, using the ".json" file extension - sdl /// SDLang based, using the ".sdl" file extension + sdl, /// SDLang based, using the ".sdl" file extension + yaml, /// YAML based, using either `.yaml` or `.yml` extension } struct FilenameAndFormat { @@ -45,9 +46,11 @@ struct FilenameAndFormat { /// Supported package descriptions in decreasing order of preference. static immutable FilenameAndFormat[] packageInfoFiles = [ - {"dub.json", PackageFormat.json}, - {"dub.sdl", PackageFormat.sdl}, - {"package.json", PackageFormat.json} + { "dub.json", PackageFormat.json }, + { "dub.sdl", PackageFormat.sdl }, + { "dub.yaml", PackageFormat.yaml }, // Official extension + { "dub.yml", PackageFormat.yaml }, // Common alternative extension + { "package.json", PackageFormat.json }, ]; /// Returns a list of all recognized package recipe file names in descending order of precedence. diff --git a/source/dub/recipe/io.d b/source/dub/recipe/io.d index cda85a6e4a..b18cae7191 100644 --- a/source/dub/recipe/io.d +++ b/source/dub/recipe/io.d @@ -90,9 +90,11 @@ PackageRecipe parsePackageRecipe(string contents, string filename, PackageRecipe ret; ret.name = default_package_name; - - if (filename.endsWith(".json")) - { + if (filename.endsWith(".yaml") || filename.endsWith(".yml")) { + // Warn users about unused field, but don't error for forward-compatibility + ret = parseConfigString!PackageRecipe(contents, filename, StrictMode.Warn); + fixDependenciesNames(ret.name, ret); + } else if (filename.endsWith(".json")) { try { ret = parseConfigString!PackageRecipe(contents, filename, mode); fixDependenciesNames(ret.name, ret); @@ -245,11 +247,15 @@ void serializePackageRecipe(R)(ref R dst, const scope ref PackageRecipe recipe, import dub.internal.vibecompat.data.json : writeJsonString; import dub.recipe.json : toJson; import dub.recipe.sdl : toSDL; + import dub.recipe.yaml : toYAML; if (filename.endsWith(".json")) dst.writeJsonString!(R, true)(toJson(recipe)); else if (filename.endsWith(".sdl")) toSDL(recipe).toSDLDocument(dst); + else if (filename.endsWith(".yaml") || filename.endsWith(".yml")) { + toJson(recipe).toYAML(dst); + } else assert(false, "writePackageRecipe called with filename with unknown extension: "~filename); } diff --git a/source/dub/recipe/yaml.d b/source/dub/recipe/yaml.d new file mode 100644 index 0000000000..3de40442c1 --- /dev/null +++ b/source/dub/recipe/yaml.d @@ -0,0 +1,97 @@ +/******************************************************************************* + + YAML serialization helper + +*******************************************************************************/ + +module dub.recipe.yaml; + +import dub.internal.vibecompat.data.json; + +import std.algorithm; +import std.array : appender, Appender; +import std.bigint; +import std.format; +import std.range; + +package string toYAML (Json json) { + auto sb = appender!string(); + serializeHelper(json, sb, 0); + return sb.data; +} + +package void toYAML (R) (Json json, ref R dst) { + serializeHelper(json, dst, 0); +} + +private void serializeHelper (R) (Json value, ref R dst, size_t indent, bool skipFirstIndent = false) { + final switch (value.type) { + case Json.Type.object: + foreach (fieldName; FieldOrder) { + if (auto ptr = fieldName in value) { + serializeField(dst, fieldName, *ptr, skipFirstIndent ? 0 : indent); + skipFirstIndent = false; + } + } + foreach (string key, fieldValue; value) { + if (FieldOrder.canFind(key)) continue; + serializeField(dst, key, fieldValue, skipFirstIndent ? 0 : indent); + skipFirstIndent = false; + } + break; + case Json.Type.array: + foreach (size_t idx, element; value) { + formattedWrite(dst, "%*.*0$s- ", indent, ` `); + + if (element.isScalar) { + serializeHelper(element, dst, 0); + } else { + serializeHelper(element, dst, indent + 2, true); + } + } + break; + case Json.Type.string: + formattedWrite(dst, `"%s"`, value.get!string); + break; + case Json.Type.bool_: + dst.put(value.get!bool ? "true" : "false"); + break; + case Json.Type.null_: + dst.put("null"); + break; + case Json.Type.int_: + formattedWrite(dst, "%s", value.get!long); + break; + case Json.Type.bigInt: + formattedWrite(dst, "%s", value.get!BigInt); + break; + case Json.Type.float_: + formattedWrite(dst, "%s", value.get!double); + break; + case Json.Type.undefined: + break; + } + if (value.isScalar) + dst.put("\n"); +} + +private void serializeField (R) (ref R dst, string key, Json fieldValue, size_t indent) { + formattedWrite(dst, "%*.*0$s%s:", indent, ` `, key); + if (fieldValue.isScalar) { + dst.put(" "); + serializeHelper(fieldValue, dst, 0); + } else { + dst.put("\n"); + serializeHelper(fieldValue, dst, indent + 2); + } +} + +private bool isScalar(Json value) { + return value.type != Json.Type.object && value.type != Json.Type.array; +} + +/// To get a better formatted YAML out of the box +private immutable FieldOrder = [ + "name", "description", "homepage", "authors", "copyright", "license", + "toolchainRequirements", "mainSourceFile", "dependencies", "configurations", +]; diff --git a/source/dub/test/others.d b/source/dub/test/others.d index be406a2cdd..f3d1b31bbe 100644 --- a/source/dub/test/others.d +++ b/source/dub/test/others.d @@ -120,3 +120,50 @@ unittest dub.loadPackage(); assert(dub.project.hasAllDependencies()); } + +// Ensure that dub recognizes `dub.yaml` +unittest +{ + scope dubJSON = new TestDub((scope Filesystem fs) { + fs.writeFile(TestDub.ProjectPath ~ "dub.json", `{"name":"json"}`); + fs.writeFile(TestDub.ProjectPath ~ "dub.sdl", `name "sdl"`); + fs.writeFile(TestDub.ProjectPath ~ "dub.yaml", `name: yaml`); + fs.writeFile(TestDub.ProjectPath ~ "dub.yml", `name: yml`); + fs.writeFile(TestDub.ProjectPath ~ "package.json", `{"name":"package"}`); + }); + dubJSON.loadPackage(); + assert(dubJSON.project.name() == "json"); + + scope dubSDL = dubJSON.newTest((scope Filesystem fs) { + fs.removeFile(TestDub.ProjectPath ~ "dub.json"); + }); + dubSDL.loadPackage(); + assert(dubSDL.project.name() == "sdl"); + + scope dubYAML = dubSDL.newTest((scope Filesystem fs) { + fs.removeFile(TestDub.ProjectPath ~ "dub.sdl"); + }); + dubYAML.loadPackage(); + assert(dubYAML.project.name() == "yaml"); + + scope dubYML = dubYAML.newTest((scope Filesystem fs) { + fs.removeFile(TestDub.ProjectPath ~ "dub.yaml"); + }); + dubYML.loadPackage(); + assert(dubYML.project.name() == "yml"); + + scope dubPackageJSON = dubYML.newTest((scope Filesystem fs) { + fs.removeFile(TestDub.ProjectPath ~ "dub.yml"); + }); + dubPackageJSON.loadPackage(); + assert(dubPackageJSON.project.name() == "package"); + + scope dubNothing = dubPackageJSON.newTest((scope Filesystem fs) { + fs.removeFile(TestDub.ProjectPath ~ "package.json"); + }); + try { + dubNothing.loadPackage(); + assert(0, "dubNothing should have thrown"); + } catch (Exception exc) + assert(exc.message().canFind("No package file found in")); +} diff --git a/test/0-init-interactive.sh b/test/0-init-interactive.sh index b46b5cd33e..eee35c1cc3 100755 --- a/test/0-init-interactive.sh +++ b/test/0-init-interactive.sh @@ -26,7 +26,7 @@ function runTest { # sdl package format runTest '1\ntest\ndesc\nauthor\ngpl\ncopy\n\n' 0-init-interactive.dub.sdl # select package format out of bounds -runTest '3\n1\ntest\ndesc\nauthor\ngpl\ncopy\n\n' 0-init-interactive.dub.sdl +runTest '4\n1\ntest\ndesc\nauthor\ngpl\ncopy\n\n' 0-init-interactive.dub.sdl # select package format not numeric, but in list runTest 'sdl\ntest\ndesc\nauthor\ngpl\ncopy\n\n' 0-init-interactive.dub.sdl # selected value not numeric and not in list