Skip to content

Commit

Permalink
Model diff tool (#1578)
Browse files Browse the repository at this point in the history
Adds "diff-spec" command that diffs any two models and summarizes changes between them.  Depth of details is
configurable and output is a color-coded summary.  Without arguments, diffs the current model against the previous
model.  With arguments you may specify the from/to models either as a spec version or specific model or element
object.

Logic is also available as an API in the model package.

Upgrades CLI input parsing to support non-boolean arguments and typed argument access.  Converts other commands to use
new API.

Adds support for running commands directly from command line so you can do e.g. `matter diff-spec`.

Implements general `FieldValue.cast` function that maps cast to proper type-specific implementation.

Adds "added" and "deleted" diagnostic display formats.
  • Loading branch information
lauckhart authored Dec 28, 2024
1 parent dee6242 commit a73d6fb
Show file tree
Hide file tree
Showing 20 changed files with 766 additions and 268 deletions.
75 changes: 71 additions & 4 deletions packages/cli-tool/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,85 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { Domain, DomainContext } from "#domain.js";
import { CommandInput } from "#parser.js";
import { repl } from "#repl.js";
import { commander } from "#tools";
import { Environment, LogFormat, MatterError } from "@matter/general";
import "@matter/nodejs";
import colors from "ansi-colors";
import { stdout } from "process";

export async function main(argv: string[]) {
colors.enabled = stdout.isTTY;

commander("matter", "Interact with the local Matter environment.").parse(argv);
let args = argv.slice(2);
if (!args.length) {
await repl();
return;
}

// TODO - REPL is enough for testing but need proper CLI
for (const arg of args) {
if (arg.startsWith("-")) {
if (arg === "--help") {
args = ["help"];
} else {
throw new MatterError(`Unknown command line argument ${arg}`);
}
} else {
// Identified command
break;
}
}

await repl();
const command: CommandInput = {
kind: "command",
name: args[0],
args: args.slice(1).map(arg => {
let js;
if (arg.startsWith("(")) {
js = arg;
} else {
js = JSON.stringify(arg);
}
return {
line: 0,
column: 0,
js,
};
}),
};

const cx: DomainContext = {
description: "matter.js",
env: Environment.default,

out(...text) {
stdout.write(text.join(""));
},

err(...text) {
let str = text.join("");
if (str.indexOf("\x1b") === -1) {
str = colors.red(str);
}
stdout.write(str);
},

get terminalWidth() {
return process.stdout.columns;
},

colorize: true,
};

const domain = await Domain(cx);
try {
const result = await domain.execute(command);
if (result !== undefined) {
domain.out(domain.inspect(result), "\n");
}
} catch (e) {
domain.err(LogFormat.ansi(e), "\n");
process.exitCode = 1;
}
}
2 changes: 1 addition & 1 deletion packages/cli-tool/src/commands/cat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Command({
aliases: ["inspect"],

invoke: async function cat(args) {
const locations = await Promise.all(args.map(path => this.location.at(`${path}`)));
const locations = await Promise.all(args._.map(path => this.location.at(`${path}`)));
for (const location of locations) {
this.out(this.inspect(location.definition), "\n");
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-tool/src/commands/cd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Command({
usage: "[PATH]",
description: "Change current working directory. If you omit PATH changes to the last node entered.",
maxArgs: 1,
positionalArgs: [{ name: "path", description: "directory to enter", type: "string" }],

invoke: async function cd([path]) {
invoke: async function cd({ path }) {
if (path === undefined) {
path = this.env.vars.get("home", "/");
} else {
Expand Down
187 changes: 144 additions & 43 deletions packages/cli-tool/src/commands/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,117 @@
*/

import { Domain } from "#domain.js";
import { FormattedText, ImplementationError, MaybePromise } from "#general";
import { decamelize, FormattedText, ImplementationError, MaybePromise } from "#general";
import { bin } from "#globals.js";
import { FieldValue, Metatype } from "#model";
import colors from "ansi-colors";

export interface CommandDefinition {
invoke: (this: Domain, args: unknown[], flags: Record<string, boolean>) => MaybePromise<unknown>;
export interface CommandDefinition<
N extends CommandDefinition.ArgDescriptor[],
P extends CommandDefinition.ArgDescriptor[],
R extends CommandDefinition.ArgDescriptor,
> {
invoke: (this: Domain, args: CommandDefinition.ArgValues<N, P, R>) => MaybePromise<unknown>;
usage?: string | string[];
description: string;
flagArgs?: Record<string, string>;
namedArgs?: N;
positionalArgs?: P;
restArgs?: R;
minArgs?: number;
maxArgs?: number;
aliases?: string[];
}

export function Command({
export namespace CommandDefinition {
export interface ArgDescriptor<T extends `${Metatype}` = any> {
name: string;
description: string;
type?: T;
default?: Metatype.Native<T>;
}

export type ArgValues<
N extends CommandDefinition.ArgDescriptor[],
P extends CommandDefinition.ArgDescriptor[],
R extends CommandDefinition.ArgDescriptor,
> = NamedArgValues<N> & NamedArgValues<P> & { _: ArgType<R>[] };

export type NamedArgValues<T extends CommandDefinition.ArgDescriptor[]> = {
[A in T[number] as A["name"]]: ArgType<A>;
};

export type ArgType<T extends CommandDefinition.ArgDescriptor> = T extends { type: string }
? Metatype.Native<T["type"]>
: boolean;
}

export function Command<
const N extends CommandDefinition.ArgDescriptor[],
const P extends CommandDefinition.ArgDescriptor[],
const R extends CommandDefinition.ArgDescriptor,
>({
invoke: doInvoke,
usage,
description,
flagArgs,
namedArgs: namedArgDescriptors,
positionalArgs: positionalArgDescriptors,
restArgs: restArgDescriptor,
minArgs,
maxArgs,
aliases,
}: CommandDefinition) {
}: CommandDefinition<N, P, R>) {
const args: Record<string, CommandDefinition.ArgDescriptor> = {
"--help": {
name: "help",
type: "boolean",
description: "Show this help",
},
};

const defaults = {} as Record<string, unknown>;

if (namedArgDescriptors) {
for (const arg of namedArgDescriptors) {
const long = arg.name.length > 1;
if (long) {
args[`--${arg.name}`] = { type: "boolean", ...arg };
} else {
args[`-${arg.name}`] = { type: "boolean", ...arg };
}
if (arg.default !== undefined) {
defaults[arg.name] = arg.default;
}
}
}

if (!positionalArgDescriptors) {
positionalArgDescriptors = [] as unknown as P;
}

function help(domain: Domain) {
const flagDetails = [
...Object.entries(flagArgs ?? {}).map(([k, v]) => [k.length > 1 ? `--${k}` : `-${k}`, v]),
const namedArgDetails = [
...Object.entries(args ?? {}).map(([k, v]) => {
let description = v.description;
if (v.default !== undefined) {
description = `${description} (default ${v.default})`;
}
return [k, description];
}),
["--help", "Show this help"],
];

const maxArgWidth = Math.max(...flagDetails.map(([arg]) => arg.length));
const maxArgWidth = Math.max(...namedArgDetails.map(([arg]) => arg.length));
const argNameWidth = maxArgWidth + 4;
const argDetailWidth = domain.terminalWidth - argNameWidth;

const argHelp = flagDetails.map(([arg, description]) => {
const argHelp = namedArgDetails.map(([arg, description]) => {
arg = colors.blue(arg.padEnd(maxArgWidth));
description = FormattedText(description, argDetailWidth).join("\n").replace(/\n/g, "".padEnd(maxArgWidth));
return ` ${arg} ${description}`;
});

let usageStr;
const name = colors.blue(doInvoke.name);
const name = colors.blue(decamelize(doInvoke.name));
switch (typeof usage) {
case "object":
usageStr = ["", ...usage.map(str => `${name}${str ? ` ${str}` : ""}`)].join("\n ");
Expand All @@ -72,70 +142,101 @@ export function Command({
);
}

const { name } = doInvoke;
const command = function invoke(this: { domain: Domain }, ...args: unknown[]) {
const name = decamelize(doInvoke.name);
const command = function invoke(this: { domain: Domain }, ...argv: unknown[]) {
const domain = this.domain;
if (!domain?.isDomain) {
throw new ImplementationError(`Domain command ${name} invoked without bin scope`);
}

const otherArgs = Array<unknown>();
const flags = {} as Record<string, boolean>;
const positionalArgs = Array<unknown>();
const inputs = { ...defaults } as Record<string, unknown>;

for (const arg of args) {
for (let i = 0; i < argv.length; i++) {
let arg = argv[i] as string;
if (typeof arg !== "string" || !arg.startsWith("-")) {
otherArgs.push(arg);
positionalArgs.push(arg);
continue;
}

if (!flagArgs) {
domain.err(`Invalid argumetn: ${arg}\n`);
return;
const splitAt = arg.indexOf("=");
let name: string;
let param: unknown;
if (splitAt) {
param = arg.slice(splitAt + 1);
arg = arg.slice(0, splitAt);
}

if (arg[1] === "-") {
const name = arg.slice(2);

if (name === "help") {
help(domain);
return;
}

if (!(name in flagArgs)) {
if (!(arg in args)) {
domain.err(`Invalid argument: ${arg}\n`);
return;
}

flags[name] = true;
continue;
}
name = arg.slice(2);

for (const name of arg.slice(1).split("")) {
if (!(name in flagArgs)) {
domain.err(`Invalid argument: ${arg}\n`);
if (name === "help") {
help(domain);
return;
}

if (!(name in flagArgs)) {
domain.err(`Invalid argument: ${arg}\n`);
return;
} else {
for (let i = 0; i < arg.length; i++) {
const subarg = `-${arg[i]}`;
if (!(subarg in args)) {
domain.err(`Invalid argument: ${subarg}\n`);
return;
}

if (i === arg.length - 1) {
arg = subarg;
name = arg[i];
} else {
if (args[subarg].type !== "boolean") {
domain.err(`Argument "${subarg}" requires a parameter`);
return;
}
inputs[subarg] = true;
continue;
}
}
}

flags[name] = true;
const definition = args[arg];
if (param === undefined && definition.type !== "boolean") {
if (i === argv.length - 1) {
domain.err(`Argument "${arg}" requires a parameter`);
}
param = argv[++i];
}

inputs[name!] = FieldValue.cast((args[arg].type ?? "boolean") as Metatype, param);
}

if (minArgs !== undefined && minArgs < args.length) {
if (minArgs !== undefined && minArgs < positionalArgs.length) {
domain.err(`Too few arguments\n`);
return;
}

if (maxArgs !== undefined && maxArgs < args.length) {
if (maxArgs !== undefined && maxArgs < positionalArgs.length) {
domain.err(`Too many arguments\n`);
return;
}

return doInvoke.call(domain, otherArgs, flags);
for (const arg of positionalArgDescriptors) {
if (!positionalArgs.length) {
break;
}
inputs[arg.name] =
arg.type === undefined ? positionalArgs.shift() : Metatype.cast(arg.type, positionalArgs.shift());
}

if (restArgDescriptor?.type === undefined) {
inputs._ = positionalArgs;
} else {
inputs._ = positionalArgs.map(arg => Metatype.cast(restArgDescriptor.type, arg));
}

return doInvoke.call(domain, inputs as CommandDefinition.ArgValues<N, P, R>);
};

command.help = help;
Expand Down
Loading

0 comments on commit a73d6fb

Please sign in to comment.