Skip to content

Commit

Permalink
feat: Support jsonc config files (#206)
Browse files Browse the repository at this point in the history
  • Loading branch information
arnauorriols authored Dec 1, 2023
1 parent f5239ed commit 103fda2
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 25 deletions.
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion deployctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ async function setDefaultsFromConfigFile(args: Args) {
args.config ?? configFile.cwdOrAncestors(),
);
if (config === null && args.config !== undefined && !args["save-config"]) {
error(`Could not find or read the config file '${args.config}'`);
error(
`Could not find or read the config file '${args.config}'. Use --save-config to create it.`,
);
}
if (config !== null) {
wait("").start().info(`Using config file '${config.path()}'`);
Expand Down
3 changes: 3 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export {
basename,
dirname,
extname,
fromFileUrl,
join,
normalize,
Expand All @@ -13,13 +14,15 @@ export {
} from "https://deno.land/[email protected]/path/mod.ts";
export {
bold,
cyan,
green,
magenta,
red,
yellow,
} from "https://deno.land/[email protected]/fmt/colors.ts";
export { parse } from "https://deno.land/[email protected]/flags/mod.ts";
export { TextLineStream } from "https://deno.land/[email protected]/streams/text_line_stream.ts";
export * as JSONC from "https://deno.land/[email protected]/encoding/jsonc.ts";

// x/semver
export {
Expand Down
128 changes: 104 additions & 24 deletions src/config_file.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
// Copyright 2021 Deno Land Inc. All rights reserved. MIT license.

import { dirname, join, relative, resolve } from "../deps.ts";
import {
cyan,
dirname,
extname,
green,
join,
JSONC,
magenta,
red,
relative,
resolve,
} from "../deps.ts";
import { error } from "./error.ts";
import { isURL } from "./utils/entrypoint.ts";
import { wait } from "./utils/spinner.ts";

const DEFAULT_FILENAME = "deno.json";
const CANDIDATE_FILENAMES = [DEFAULT_FILENAME, "deno.jsonc"];

/** Arguments persisted in the deno.json config file */
interface ConfigArgs {
Expand Down Expand Up @@ -63,28 +75,33 @@ class ConfigFile {
}
}

/** Check if the `ConfigArgs` in this `ConfigFile` match `args`
/** Returns all the differences between this `ConfigArgs` and the one provided as argument.
*
* Ignores any property in `args` not meant to be persisted.
* The comparison is performed against the JSON output of each config. The "other" args are
* sematically considered additions in the return value. Ignores any property in `args` not meant
* to be persisted.
*/
eq(args: ConfigArgs) {
const otherConfigArgs = this.normalize(args);
diff(args: ConfigArgs): Change[] {
const changes = [];
const otherConfigOutput =
JSON.parse(ConfigFile.create(this.path(), args).toFileContent()).deploy ??
{};
const thisConfigOutput = JSON.parse(this.toFileContent()).deploy ?? {};
// Iterate over the other args as they might include args not yet persisted in the config file
for (const [key, otherValue] of Object.entries(otherConfigArgs)) {
// deno-lint-ignore no-explicit-any
const thisValue = (this.args() as any)[key];
if (otherValue instanceof Array) {
const thisArrayValue = thisValue as typeof otherValue;
if (thisArrayValue.length !== otherValue.length) {
return false;
} else if (!thisArrayValue.every((x, i) => otherValue[i] === x)) {
return false;
for (const [key, otherValue] of Object.entries(otherConfigOutput)) {
const thisValue = thisConfigOutput[key];
if (Array.isArray(otherValue) && Array.isArray(thisValue)) {
if (
thisValue.length !== otherValue.length ||
!thisValue.every((x, i) => otherValue[i] === x)
) {
changes.push({ key, removal: thisValue, addition: otherValue });
}
} else if (thisValue !== otherValue) {
return false;
changes.push({ key, removal: thisValue, addition: otherValue });
}
}
return true;
return changes;
}

normalize(args: ConfigArgs): ConfigArgs {
Expand All @@ -107,7 +124,7 @@ class ConfigFile {
}

static fromFileContent(filepath: string, content: string) {
const parsedContent = JSON.parse(content);
const parsedContent = JSONC.parse(content) as { deploy?: ConfigArgs };
const configContent = {
...parsedContent,
deploy: parsedContent.deploy && {
Expand Down Expand Up @@ -187,19 +204,49 @@ export default {
overwrite: boolean,
): Promise<void> {
const pathOrDefault = path ?? DEFAULT_FILENAME;
const isJsonc = extname(pathOrDefault) === ".jsonc";
const existingConfig = await this.read(pathOrDefault);
const changes = existingConfig?.diff(args) ?? [];
let config;
if (existingConfig && existingConfig.hasDeployConfig() && !overwrite) {
if (!existingConfig.eq(args)) {
wait("").start().info(
`Some of the config used differ from the config found in '${existingConfig.path()}'. Use --save-config to overwrite it.`,
);
}
if (existingConfig && changes.length === 0) {
// There are no changes to write
return;
} else if (
existingConfig && existingConfig.hasDeployConfig() && !overwrite
) {
// There are changes to write and there's already some deploy config, we require the --save-config flag
wait("").start().info(
`Some of the config used differ from the config found in '${existingConfig.path()}'. Use --save-config to overwrite it.`,
);
return;
} else if (existingConfig) {
// Either there is no deploy config in the config file or the user is using --save-config flag
if (isJsonc) {
const msg = overwrite
? `Writing to the config file '${pathOrDefault}' will remove any existing comment and format it as a plain JSON file. Is that ok?`
: `I want to store some configuration in '${pathOrDefault}' config file but this will remove any existing comment from it. Is that ok?`;
const confirmation = confirm(`${magenta("?")} ${msg}`);
if (!confirmation) {
const formattedChanges = existingConfig.hasDeployConfig()
? cyan(
` "deploy": {\n ...\n${formatChanges(changes, 2, 2)}\n }`,
)
: green(
ConfigFile.create(pathOrDefault, args).toFileContent().slice(
2,
-2,
),
);
wait({ text: "", indent: 3 }).start().info(
`I understand. Here's the config I wanted to write:\n${formattedChanges}`,
);
return;
}
}
existingConfig.override(args);
config = existingConfig;
} else {
// The config file does not exist. Create a new one.
config = ConfigFile.create(pathOrDefault, args);
}
await Deno.writeTextFile(
Expand All @@ -216,7 +263,9 @@ export default {
cwdOrAncestors: function* () {
let wd = Deno.cwd();
while (wd) {
yield join(wd, DEFAULT_FILENAME);
for (const filename of CANDIDATE_FILENAMES) {
yield join(wd, filename);
}
const newWd = dirname(wd);
if (newWd === wd) {
return;
Expand All @@ -226,3 +275,34 @@ export default {
}
},
};

function formatChanges(
changes: Change[],
indent?: number,
gap?: number,
): string {
const removals = [];
const additions = [];
const padding = " ".repeat(indent ?? 0);
const innerPadding = " ".repeat(gap ?? 0);
for (const { key, removal, addition } of changes) {
if (removal !== undefined) {
removals.push(red(
`${padding}-${innerPadding}"${key}": ${JSON.stringify(removal)}`,
));
}
if (addition !== undefined) {
additions.push(green(
`${padding}+${innerPadding}"${key}": ${JSON.stringify(addition)}`,
));
}
}
return [removals.join(red(",\n")), additions.join(green(",\n"))].join("\n")
.trim();
}

interface Change {
key: string;
removal?: unknown;
addition?: unknown;
}
1 change: 1 addition & 0 deletions tests/config_file_test/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
51 changes: 51 additions & 0 deletions tests/config_file_test/config_file_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { fromFileUrl } from "../../deps.ts";
import configFile from "../../src/config_file.ts";
import { assert, assertEquals } from "../deps.ts";

Deno.test("ConfigFile.diff returns array with additions and removals", async () => {
const config = await configFile.read(
fromFileUrl(new URL(import.meta.resolve("./config.json"))),
);
assert(!!config);

let changes = config.diff({});
assertEquals(changes, []);

changes = config.diff({ project: "foo" });
assertEquals(changes, [{
key: "project",
addition: "foo",
removal: undefined,
}]);

// Using file URLs to avoid dealing with path normalization
config.override({ project: "foo", entrypoint: "file://main.ts" });

changes = config.diff({ project: "bar", entrypoint: "file://src/main.ts" });
assertEquals(changes, [
{ key: "project", removal: "foo", addition: "bar" },
{
key: "entrypoint",
removal: "file://main.ts",
addition: "file://src/main.ts",
},
]);
});

Deno.test("ConfigFile.diff reports inculde and exclude changes when one of the entries changed", async () => {
const config = await configFile.read(
fromFileUrl(new URL(import.meta.resolve("./config.json"))),
);
assert(!!config);

config.override({ include: ["foo", "bar"], exclude: ["fuzz", "bazz"] });

const changes = config.diff({
include: ["fuzz", "bazz"],
exclude: ["foo", "bar"],
});
assertEquals(changes, [
{ key: "exclude", addition: ["foo", "bar"], removal: ["fuzz", "bazz"] },
{ key: "include", removal: ["foo", "bar"], addition: ["fuzz", "bazz"] },
]);
});

0 comments on commit 103fda2

Please sign in to comment.