Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support jsonc config files #206

Merged
merged 10 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -114,7 +114,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";
arnauorriols marked this conversation as resolved.
Show resolved Hide resolved

// 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",
},
]);
});
arnauorriols marked this conversation as resolved.
Show resolved Hide resolved

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"] },
]);
});