@kgdiff/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/dist/apply-command.d.ts +34 -0
- package/dist/apply-command.d.ts.map +1 -0
- package/dist/apply-command.js +137 -0
- package/dist/apply-command.js.map +1 -0
- package/dist/args.d.ts +96 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +463 -0
- package/dist/args.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +17 -0
- package/dist/bin.js.map +1 -0
- package/dist/diff-command.d.ts +29 -0
- package/dist/diff-command.d.ts.map +1 -0
- package/dist/diff-command.js +236 -0
- package/dist/diff-command.js.map +1 -0
- package/dist/errors.d.ts +48 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +92 -0
- package/dist/errors.js.map +1 -0
- package/dist/help.d.ts +11 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +247 -0
- package/dist/help.js.map +1 -0
- package/dist/html.d.ts +53 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +107 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs.d.ts +54 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +209 -0
- package/dist/inputs.js.map +1 -0
- package/dist/io.d.ts +64 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +2 -0
- package/dist/io.js.map +1 -0
- package/dist/locale.d.ts +58 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +121 -0
- package/dist/locale.js.map +1 -0
- package/dist/main.d.ts +10 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +51 -0
- package/dist/main.js.map +1 -0
- package/dist/markdown.d.ts +30 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +93 -0
- package/dist/markdown.js.map +1 -0
- package/dist/model.d.ts +63 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +151 -0
- package/dist/model.js.map +1 -0
- package/dist/node-io.d.ts +12 -0
- package/dist/node-io.d.ts.map +1 -0
- package/dist/node-io.js +135 -0
- package/dist/node-io.js.map +1 -0
- package/dist/output.d.ts +27 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +50 -0
- package/dist/output.js.map +1 -0
- package/dist/overwrite.d.ts +14 -0
- package/dist/overwrite.d.ts.map +1 -0
- package/dist/overwrite.js +55 -0
- package/dist/overwrite.js.map +1 -0
- package/dist/report.d.ts +52 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +64 -0
- package/dist/report.js.map +1 -0
- package/dist/style.d.ts +46 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +140 -0
- package/dist/style.js.map +1 -0
- package/dist/summary.d.ts +40 -0
- package/dist/summary.d.ts.map +1 -0
- package/dist/summary.js +352 -0
- package/dist/summary.js.map +1 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/dist/width.d.ts +45 -0
- package/dist/width.d.ts.map +1 -0
- package/dist/width.js +232 -0
- package/dist/width.js.map +1 -0
- package/package.json +56 -3
- package/src/apply-command.ts +203 -0
- package/src/args.ts +605 -0
- package/src/bin.ts +22 -0
- package/src/diff-command.ts +299 -0
- package/src/errors.ts +131 -0
- package/src/help.ts +286 -0
- package/src/html.ts +145 -0
- package/src/index.ts +71 -0
- package/src/inputs.ts +269 -0
- package/src/io.ts +68 -0
- package/src/locale.ts +177 -0
- package/src/main.ts +56 -0
- package/src/markdown.ts +119 -0
- package/src/model.ts +181 -0
- package/src/node-io.ts +146 -0
- package/src/output.ts +81 -0
- package/src/overwrite.ts +75 -0
- package/src/report.ts +101 -0
- package/src/style.ts +164 -0
- package/src/summary.ts +440 -0
- package/src/version.ts +21 -0
- package/src/width.ts +222 -0
package/src/args.ts
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-line parsing and validation (docs/cli.md §Usage).
|
|
3
|
+
*
|
|
4
|
+
* Built on `node:util` `parseArgs` (tokens mode) with three additions: options with an
|
|
5
|
+
* optional value (`--json [file]`, `--delta [file]`, `--markdown [file]`), "did you mean"
|
|
6
|
+
* suggestions for unknown options and values, and validation into typed commands. Pure: no I/O.
|
|
7
|
+
*
|
|
8
|
+
* Optional values: `--json=out.json` always binds; `--json out.json` binds when the next
|
|
9
|
+
* argument does not start with `-`, unless it is needed as one of the two graph files
|
|
10
|
+
* (`kgdiff --json a.ttl b.ttl` writes JSON to stdout).
|
|
11
|
+
*/
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
import type { DiffOptions, EdgeIdentity, Locale, MatchOptions } from '@kgdiff/core';
|
|
14
|
+
import type { FormatId } from '@kgdiff/formats';
|
|
15
|
+
import type { ThemePreference, ViewerMode } from '@kgdiff/viewer';
|
|
16
|
+
import { CliError } from './errors.js';
|
|
17
|
+
import { matchLocale } from './locale.js';
|
|
18
|
+
import type { ColorMode } from './style.js';
|
|
19
|
+
|
|
20
|
+
/** Path value meaning "write to stdout" / "read from stdin". */
|
|
21
|
+
export const STDIO = '-';
|
|
22
|
+
|
|
23
|
+
export const FORMATS: readonly (FormatId | 'auto')[] = [
|
|
24
|
+
'auto',
|
|
25
|
+
'json',
|
|
26
|
+
'jsonld',
|
|
27
|
+
'turtle',
|
|
28
|
+
'ntriples',
|
|
29
|
+
'nquads',
|
|
30
|
+
'trig',
|
|
31
|
+
'rdfxml',
|
|
32
|
+
'obo',
|
|
33
|
+
];
|
|
34
|
+
const MATCH_STRATEGIES = ['id', 'auto', 'similarity'] as const;
|
|
35
|
+
const EDGE_IDENTITIES: readonly EdgeIdentity[] = ['structure', 'id'];
|
|
36
|
+
const THEMES: readonly ThemePreference[] = ['auto', 'light', 'dark'];
|
|
37
|
+
const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay', 'changes'];
|
|
38
|
+
const COLOR_MODES: readonly ColorMode[] = ['auto', 'always', 'never'];
|
|
39
|
+
|
|
40
|
+
/** Default `--limit`. */
|
|
41
|
+
export const DEFAULT_LIMIT = 50;
|
|
42
|
+
|
|
43
|
+
export interface CommonOptions {
|
|
44
|
+
readonly color: ColorMode;
|
|
45
|
+
readonly quiet: boolean;
|
|
46
|
+
readonly locale?: Locale;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DiffCommandOptions extends CommonOptions {
|
|
50
|
+
/** Paths, or `-` for stdin. */
|
|
51
|
+
readonly before: string;
|
|
52
|
+
readonly after: string;
|
|
53
|
+
readonly beforeFormat: FormatId | 'auto';
|
|
54
|
+
readonly afterFormat: FormatId | 'auto';
|
|
55
|
+
/** `--lang`: RDF label language preference. */
|
|
56
|
+
readonly languages?: readonly string[];
|
|
57
|
+
readonly diff: DiffOptions;
|
|
58
|
+
readonly stat: boolean;
|
|
59
|
+
readonly nameStatus: boolean;
|
|
60
|
+
/** Output path, or `-` for stdout. */
|
|
61
|
+
readonly json?: string;
|
|
62
|
+
readonly delta?: string;
|
|
63
|
+
readonly markdown?: string;
|
|
64
|
+
readonly html?: string;
|
|
65
|
+
readonly open: boolean;
|
|
66
|
+
readonly pretty: boolean;
|
|
67
|
+
readonly title?: string;
|
|
68
|
+
readonly theme?: ThemePreference;
|
|
69
|
+
readonly mode?: ViewerMode;
|
|
70
|
+
readonly reproducible: boolean;
|
|
71
|
+
/** `--allow-empty`: an empty input is an empty graph instead of an error. */
|
|
72
|
+
readonly allowEmpty: boolean;
|
|
73
|
+
/** Inline the viewer's compute add-on when the report needs it (`--no-compute` disables). */
|
|
74
|
+
readonly compute: boolean;
|
|
75
|
+
readonly exitCode: boolean;
|
|
76
|
+
/** Max hunks in the summary; 0 = all. */
|
|
77
|
+
readonly limit: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ApplyCommandOptions extends CommonOptions {
|
|
81
|
+
readonly graph: string;
|
|
82
|
+
readonly delta: string;
|
|
83
|
+
/** Output path, or `-` for stdout. */
|
|
84
|
+
readonly output: string;
|
|
85
|
+
readonly strict: boolean;
|
|
86
|
+
readonly format: FormatId | 'auto';
|
|
87
|
+
readonly languages?: readonly string[];
|
|
88
|
+
readonly pretty: boolean;
|
|
89
|
+
/** `--allow-empty`: an empty <graph> is an empty graph instead of an error. */
|
|
90
|
+
readonly allowEmpty: boolean;
|
|
91
|
+
/** `--verbose`: list the operations `--no-strict` skipped. */
|
|
92
|
+
readonly verbose: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type Command =
|
|
96
|
+
| { readonly kind: 'help'; readonly topic: 'main' | 'apply'; readonly color: ColorMode }
|
|
97
|
+
| { readonly kind: 'version' }
|
|
98
|
+
| { readonly kind: 'diff'; readonly options: DiffCommandOptions }
|
|
99
|
+
| { readonly kind: 'apply'; readonly options: ApplyCommandOptions };
|
|
100
|
+
|
|
101
|
+
interface OptionSpec {
|
|
102
|
+
readonly type: 'boolean' | 'string';
|
|
103
|
+
readonly short?: string;
|
|
104
|
+
readonly multiple?: boolean;
|
|
105
|
+
/** `--json [file]`: a string option whose value may be omitted. */
|
|
106
|
+
readonly optionalValue?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const COMMON: Readonly<Record<string, OptionSpec>> = {
|
|
110
|
+
help: { type: 'boolean', short: 'h' },
|
|
111
|
+
version: { type: 'boolean', short: 'v' },
|
|
112
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
113
|
+
color: { type: 'string' },
|
|
114
|
+
locale: { type: 'string' },
|
|
115
|
+
lang: { type: 'string' },
|
|
116
|
+
pretty: { type: 'boolean' },
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Options of `kgdiff <before> <after>`. */
|
|
120
|
+
export const DIFF_OPTIONS: Readonly<Record<string, OptionSpec>> = {
|
|
121
|
+
...COMMON,
|
|
122
|
+
stat: { type: 'boolean' },
|
|
123
|
+
'name-status': { type: 'boolean' },
|
|
124
|
+
json: { type: 'string', optionalValue: true },
|
|
125
|
+
delta: { type: 'string', optionalValue: true },
|
|
126
|
+
markdown: { type: 'string', optionalValue: true },
|
|
127
|
+
html: { type: 'string', short: 'o' },
|
|
128
|
+
open: { type: 'boolean' },
|
|
129
|
+
format: { type: 'string', short: 'f' },
|
|
130
|
+
'before-format': { type: 'string' },
|
|
131
|
+
'after-format': { type: 'string' },
|
|
132
|
+
match: { type: 'string' },
|
|
133
|
+
threshold: { type: 'string' },
|
|
134
|
+
'edge-identity': { type: 'string' },
|
|
135
|
+
hierarchy: { type: 'string', multiple: true },
|
|
136
|
+
'ignore-attr': { type: 'string', multiple: true },
|
|
137
|
+
'no-merges': { type: 'boolean' },
|
|
138
|
+
'no-splits': { type: 'boolean' },
|
|
139
|
+
title: { type: 'string' },
|
|
140
|
+
theme: { type: 'string' },
|
|
141
|
+
mode: { type: 'string' },
|
|
142
|
+
reproducible: { type: 'boolean' },
|
|
143
|
+
'no-compute': { type: 'boolean' },
|
|
144
|
+
'allow-empty': { type: 'boolean' },
|
|
145
|
+
'exit-code': { type: 'boolean' },
|
|
146
|
+
limit: { type: 'string' },
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Options of `kgdiff apply <graph> <delta>`. */
|
|
150
|
+
export const APPLY_OPTIONS: Readonly<Record<string, OptionSpec>> = {
|
|
151
|
+
...COMMON,
|
|
152
|
+
output: { type: 'string', short: 'o' },
|
|
153
|
+
format: { type: 'string', short: 'f' },
|
|
154
|
+
'no-strict': { type: 'boolean' },
|
|
155
|
+
'allow-empty': { type: 'boolean' },
|
|
156
|
+
verbose: { type: 'boolean' },
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Edit distance (insertions, deletions, substitutions, adjacent transpositions). */
|
|
160
|
+
export function editDistance(a: string, b: string): number {
|
|
161
|
+
const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
162
|
+
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
163
|
+
);
|
|
164
|
+
for (let i = 1; i <= a.length; i++) {
|
|
165
|
+
for (let j = 1; j <= b.length; j++) {
|
|
166
|
+
const row = d[i] as number[];
|
|
167
|
+
const prev = d[i - 1] as number[];
|
|
168
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
169
|
+
row[j] = Math.min(
|
|
170
|
+
(prev[j] as number) + 1,
|
|
171
|
+
(row[j - 1] as number) + 1,
|
|
172
|
+
(prev[j - 1] as number) + cost,
|
|
173
|
+
);
|
|
174
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
175
|
+
row[j] = Math.min(row[j] as number, ((d[i - 2] as number[])[j - 2] as number) + 1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return (d[a.length] as number[])[b.length] as number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Closest candidate within a small edit distance (or sharing a prefix), if any. */
|
|
183
|
+
export function suggest(input: string, candidates: readonly string[]): string | undefined {
|
|
184
|
+
let best: string | undefined;
|
|
185
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
186
|
+
const needle = input.toLowerCase();
|
|
187
|
+
for (const c of candidates) {
|
|
188
|
+
const distance = editDistance(needle, c.toLowerCase());
|
|
189
|
+
const limit = Math.max(1, Math.min(3, Math.floor(c.length / 3)));
|
|
190
|
+
const score = c.startsWith(needle) && needle.length >= 2 ? 0.5 : distance;
|
|
191
|
+
if (score <= limit && score < bestScore) {
|
|
192
|
+
best = c;
|
|
193
|
+
bestScore = score;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return best;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const HELP_HINT = 'Run `kgdiff --help` for usage.';
|
|
200
|
+
|
|
201
|
+
/** Options whose value is a file path (a leading `-` can be written `./-name`). */
|
|
202
|
+
function looksLikePath(name: string): boolean {
|
|
203
|
+
return (
|
|
204
|
+
name === 'html' ||
|
|
205
|
+
name === 'output' ||
|
|
206
|
+
name === 'json' ||
|
|
207
|
+
name === 'delta' ||
|
|
208
|
+
name === 'markdown'
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function usageError(message: string, hint = HELP_HINT): CliError {
|
|
213
|
+
return new CliError('usage', message, hint);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function choice<T extends string>(name: string, value: string, choices: readonly T[]): T {
|
|
217
|
+
if ((choices as readonly string[]).includes(value)) return value as T;
|
|
218
|
+
const close = suggest(value, choices);
|
|
219
|
+
throw usageError(
|
|
220
|
+
`Invalid value "${value}" for --${name}.`,
|
|
221
|
+
`${close ? `Did you mean "${close}"? ` : ''}Expected one of: ${choices.join(', ')}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface RawToken {
|
|
226
|
+
readonly kind: 'option' | 'positional' | 'option-terminator';
|
|
227
|
+
readonly index: number;
|
|
228
|
+
readonly name?: string;
|
|
229
|
+
readonly rawName?: string;
|
|
230
|
+
readonly value?: string;
|
|
231
|
+
readonly inlineValue?: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
interface RawParse {
|
|
235
|
+
readonly values: Map<string, (string | true)[]>;
|
|
236
|
+
readonly positionals: string[];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Tokenize with `parseArgs` and apply kgdiff's rules: known options only, string options need a
|
|
241
|
+
* value (one that starts with `-` must be attached with `=`), booleans take none, optional-value
|
|
242
|
+
* options take the next argument unless it is needed as a positional.
|
|
243
|
+
*/
|
|
244
|
+
function rawParse(
|
|
245
|
+
argv: readonly string[],
|
|
246
|
+
spec: Readonly<Record<string, OptionSpec>>,
|
|
247
|
+
positionalsNeeded: number,
|
|
248
|
+
): RawParse {
|
|
249
|
+
const options: Record<
|
|
250
|
+
string,
|
|
251
|
+
{ type: 'boolean' | 'string'; short?: string; multiple?: boolean }
|
|
252
|
+
> = {};
|
|
253
|
+
for (const [name, s] of Object.entries(spec)) {
|
|
254
|
+
options[name] = {
|
|
255
|
+
type: s.optionalValue ? 'boolean' : s.type,
|
|
256
|
+
...(s.short ? { short: s.short } : {}),
|
|
257
|
+
multiple: true,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
let tokens: RawToken[];
|
|
261
|
+
try {
|
|
262
|
+
tokens = parseArgs({
|
|
263
|
+
args: [...argv],
|
|
264
|
+
options,
|
|
265
|
+
strict: false,
|
|
266
|
+
allowPositionals: true,
|
|
267
|
+
tokens: true,
|
|
268
|
+
}).tokens as RawToken[];
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw usageError(error instanceof Error ? error.message : String(error));
|
|
271
|
+
}
|
|
272
|
+
const known = Object.keys(spec);
|
|
273
|
+
let afterTerminator = false;
|
|
274
|
+
const positionals: { index: number; value: string; candidateFor?: number }[] = [];
|
|
275
|
+
const optionTokens: { token: RawToken; value?: string }[] = [];
|
|
276
|
+
for (const token of tokens) {
|
|
277
|
+
if (token.kind === 'option-terminator') {
|
|
278
|
+
afterTerminator = true;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (token.kind === 'positional') {
|
|
282
|
+
const prev = optionTokens[optionTokens.length - 1];
|
|
283
|
+
const candidate =
|
|
284
|
+
!afterTerminator &&
|
|
285
|
+
prev !== undefined &&
|
|
286
|
+
prev.token.index === token.index - 1 &&
|
|
287
|
+
spec[prev.token.name ?? '']?.optionalValue === true &&
|
|
288
|
+
prev.token.value === undefined &&
|
|
289
|
+
(!(token.value ?? '').startsWith('-') || token.value === STDIO);
|
|
290
|
+
positionals.push({
|
|
291
|
+
index: token.index,
|
|
292
|
+
value: token.value ?? '',
|
|
293
|
+
...(candidate ? { candidateFor: optionTokens.length - 1 } : {}),
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const name = token.name ?? '';
|
|
298
|
+
const s = spec[name];
|
|
299
|
+
if (!s) {
|
|
300
|
+
const raw = token.rawName ?? name;
|
|
301
|
+
const display = raw.startsWith('-') ? raw : `--${raw}`;
|
|
302
|
+
const close = raw.startsWith('--') || raw.length > 2 ? suggest(name, known) : undefined;
|
|
303
|
+
throw usageError(
|
|
304
|
+
`Unknown option ${display}.`,
|
|
305
|
+
close ? `Did you mean --${close}? ${HELP_HINT}` : HELP_HINT,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const flag = token.rawName ?? `--${name}`;
|
|
309
|
+
if (s.type === 'boolean' && token.value !== undefined) {
|
|
310
|
+
throw usageError(`Option ${flag} does not take a value.`);
|
|
311
|
+
}
|
|
312
|
+
if (s.type === 'string' && !s.optionalValue) {
|
|
313
|
+
if (token.value === undefined) throw usageError(`Option ${flag} needs a value.`);
|
|
314
|
+
if (!token.inlineValue && token.value.startsWith('-') && token.value !== STDIO) {
|
|
315
|
+
throw usageError(
|
|
316
|
+
`Option ${flag} needs a value, got "${token.value}".`,
|
|
317
|
+
`If that is the value, write --${name}=${token.value}${looksLikePath(name) ? ` or ${flag} ./${token.value}` : ''}. ${HELP_HINT}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
optionTokens.push({ token, ...(token.value !== undefined ? { value: token.value } : {}) });
|
|
322
|
+
}
|
|
323
|
+
// Optional values bind greedily; give them back (earliest first) while positionals are missing.
|
|
324
|
+
const free = (): number => positionals.filter((p) => p.candidateFor === undefined).length;
|
|
325
|
+
for (const p of positionals) {
|
|
326
|
+
if (free() >= positionalsNeeded) break;
|
|
327
|
+
if (p.candidateFor !== undefined) delete p.candidateFor;
|
|
328
|
+
}
|
|
329
|
+
for (const p of positionals) {
|
|
330
|
+
const owner = p.candidateFor !== undefined ? optionTokens[p.candidateFor] : undefined;
|
|
331
|
+
if (owner) owner.value = p.value;
|
|
332
|
+
}
|
|
333
|
+
const values = new Map<string, (string | true)[]>();
|
|
334
|
+
for (const { token, value } of optionTokens) {
|
|
335
|
+
const name = token.name as string;
|
|
336
|
+
const list = values.get(name) ?? [];
|
|
337
|
+
list.push(value ?? true);
|
|
338
|
+
values.set(name, list);
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
values,
|
|
342
|
+
positionals: positionals.filter((p) => p.candidateFor === undefined).map((p) => p.value),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class Values {
|
|
347
|
+
constructor(private readonly raw: RawParse) {}
|
|
348
|
+
flag(name: string): boolean {
|
|
349
|
+
return this.raw.values.has(name);
|
|
350
|
+
}
|
|
351
|
+
/** Last value of a string option. */
|
|
352
|
+
string(name: string): string | undefined {
|
|
353
|
+
const v = this.raw.values.get(name)?.at(-1);
|
|
354
|
+
return typeof v === 'string' ? v : undefined;
|
|
355
|
+
}
|
|
356
|
+
/** Optional-value option: `undefined` when absent, `-` (stdout) when given without a value. */
|
|
357
|
+
target(name: string): string | undefined {
|
|
358
|
+
const list = this.raw.values.get(name);
|
|
359
|
+
if (!list) return undefined;
|
|
360
|
+
const v = list.at(-1);
|
|
361
|
+
return typeof v === 'string' && v !== '' ? v : STDIO;
|
|
362
|
+
}
|
|
363
|
+
strings(name: string): string[] {
|
|
364
|
+
return (this.raw.values.get(name) ?? []).filter((v): v is string => typeof v === 'string');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseCommon(v: Values): CommonOptions {
|
|
369
|
+
const color = v.string('color');
|
|
370
|
+
const locale = v.string('locale');
|
|
371
|
+
let resolvedLocale: Locale | undefined;
|
|
372
|
+
if (locale !== undefined) {
|
|
373
|
+
resolvedLocale = matchLocale(locale);
|
|
374
|
+
if (!resolvedLocale) {
|
|
375
|
+
throw usageError(`Unsupported locale "${locale}".`, 'Expected en or zh-CN.');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
color: color === undefined ? 'auto' : choice('color', color, COLOR_MODES),
|
|
380
|
+
quiet: v.flag('quiet'),
|
|
381
|
+
...(resolvedLocale ? { locale: resolvedLocale } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function parseLanguages(v: Values): readonly string[] | undefined {
|
|
386
|
+
const lang = v.string('lang');
|
|
387
|
+
if (lang === undefined) return undefined;
|
|
388
|
+
const tags = lang.split(',').map((t) => t.trim());
|
|
389
|
+
if (tags.some((t) => !/^[A-Za-z0-9-]*$/.test(t))) {
|
|
390
|
+
throw usageError(`Invalid --lang "${lang}".`, 'Use comma-separated language tags, e.g. zh,en.');
|
|
391
|
+
}
|
|
392
|
+
return tags;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parseFormat(name: string, value: string | undefined): FormatId | 'auto' | undefined {
|
|
396
|
+
return value === undefined ? undefined : choice(name, value, FORMATS);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function parseNumber(
|
|
400
|
+
name: string,
|
|
401
|
+
value: string,
|
|
402
|
+
min: number,
|
|
403
|
+
max: number,
|
|
404
|
+
integer: boolean,
|
|
405
|
+
): number {
|
|
406
|
+
const n = Number(value);
|
|
407
|
+
if (
|
|
408
|
+
value.trim() === '' ||
|
|
409
|
+
!Number.isFinite(n) ||
|
|
410
|
+
n < min ||
|
|
411
|
+
n > max ||
|
|
412
|
+
(integer && !Number.isInteger(n))
|
|
413
|
+
) {
|
|
414
|
+
const range = integer ? `an integer ≥ ${min}` : `a number between ${min} and ${max}`;
|
|
415
|
+
throw usageError(`Invalid value "${value}" for --${name}.`, `Expected ${range}.`);
|
|
416
|
+
}
|
|
417
|
+
return n;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function missingGraphs(found: number, names: readonly [string, string]): CliError {
|
|
421
|
+
const missing = names.slice(found).map((n) => `<${n}>`);
|
|
422
|
+
return usageError(
|
|
423
|
+
`Missing ${missing.join(' and ')} ${missing.length === 1 ? 'argument' : 'arguments'}.`,
|
|
424
|
+
`Usage: kgdiff ${names[0] === 'graph' ? 'apply ' : ''}<${names[0]}> <${names[1]}> [options]. ${HELP_HINT}`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function checkPositionals(
|
|
429
|
+
positionals: readonly string[],
|
|
430
|
+
names: readonly [string, string],
|
|
431
|
+
): [string, string] {
|
|
432
|
+
if (positionals.length < 2) throw missingGraphs(positionals.length, names);
|
|
433
|
+
if (positionals.length > 2) {
|
|
434
|
+
const extra = positionals
|
|
435
|
+
.slice(2)
|
|
436
|
+
.map((p) => `"${p}"`)
|
|
437
|
+
.join(', ');
|
|
438
|
+
throw usageError(
|
|
439
|
+
`Unexpected ${positionals.length > 3 ? 'arguments' : 'argument'} ${extra}.`,
|
|
440
|
+
`kgdiff ${names[0] === 'graph' ? 'apply ' : ''}takes exactly two files: <${names[0]}> <${names[1]}>. ${HELP_HINT}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const [a, b] = positionals as [string, string];
|
|
444
|
+
if (a === STDIO && b === STDIO) {
|
|
445
|
+
throw usageError(
|
|
446
|
+
'Only one input can be read from stdin.',
|
|
447
|
+
'Pass a file path for the other side.',
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return [a, b];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function parseDiff(argv: readonly string[]): Command {
|
|
454
|
+
const raw = rawParse(argv, DIFF_OPTIONS, 2);
|
|
455
|
+
const v = new Values(raw);
|
|
456
|
+
const common = parseCommon(v);
|
|
457
|
+
if (v.flag('help')) return { kind: 'help', topic: 'main', color: common.color };
|
|
458
|
+
if (v.flag('version')) return { kind: 'version' };
|
|
459
|
+
const [before, after] = checkPositionals(raw.positionals, ['before', 'after']);
|
|
460
|
+
|
|
461
|
+
const format = parseFormat('format', v.string('format')) ?? 'auto';
|
|
462
|
+
const match: { -readonly [K in keyof MatchOptions]: MatchOptions[K] } = {};
|
|
463
|
+
const strategy = v.string('match');
|
|
464
|
+
if (strategy !== undefined) match.strategy = choice('match', strategy, MATCH_STRATEGIES);
|
|
465
|
+
const threshold = v.string('threshold');
|
|
466
|
+
if (threshold !== undefined) match.threshold = parseNumber('threshold', threshold, 0, 1, false);
|
|
467
|
+
const edgeIdentity = v.string('edge-identity');
|
|
468
|
+
const hierarchy = v.strings('hierarchy');
|
|
469
|
+
const ignore = v.strings('ignore-attr');
|
|
470
|
+
const diff: DiffOptions = {
|
|
471
|
+
...(Object.keys(match).length > 0 ? { match } : {}),
|
|
472
|
+
...(edgeIdentity !== undefined
|
|
473
|
+
? { edgeIdentity: choice('edge-identity', edgeIdentity, EDGE_IDENTITIES) }
|
|
474
|
+
: {}),
|
|
475
|
+
...(hierarchy.length > 0 ? { hierarchyPredicates: hierarchy } : {}),
|
|
476
|
+
...(ignore.length > 0 ? { ignoreAttrs: ignore } : {}),
|
|
477
|
+
...(v.flag('no-merges') ? { detectMerges: false } : {}),
|
|
478
|
+
...(v.flag('no-splits') ? { detectSplits: false } : {}),
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const theme = v.string('theme');
|
|
482
|
+
const mode = v.string('mode');
|
|
483
|
+
const limit = v.string('limit');
|
|
484
|
+
const languages = parseLanguages(v);
|
|
485
|
+
const title = v.string('title');
|
|
486
|
+
const json = v.target('json');
|
|
487
|
+
const delta = v.target('delta');
|
|
488
|
+
const markdown = v.target('markdown');
|
|
489
|
+
const html = v.string('html');
|
|
490
|
+
const options: DiffCommandOptions = {
|
|
491
|
+
...common,
|
|
492
|
+
before,
|
|
493
|
+
after,
|
|
494
|
+
beforeFormat: parseFormat('before-format', v.string('before-format')) ?? format,
|
|
495
|
+
afterFormat: parseFormat('after-format', v.string('after-format')) ?? format,
|
|
496
|
+
...(languages ? { languages } : {}),
|
|
497
|
+
diff,
|
|
498
|
+
stat: v.flag('stat'),
|
|
499
|
+
nameStatus: v.flag('name-status'),
|
|
500
|
+
...(json !== undefined ? { json } : {}),
|
|
501
|
+
...(delta !== undefined ? { delta } : {}),
|
|
502
|
+
...(markdown !== undefined ? { markdown } : {}),
|
|
503
|
+
...(html !== undefined ? { html } : {}),
|
|
504
|
+
open: v.flag('open'),
|
|
505
|
+
pretty: v.flag('pretty'),
|
|
506
|
+
...(title !== undefined ? { title } : {}),
|
|
507
|
+
...(theme !== undefined ? { theme: choice('theme', theme, THEMES) } : {}),
|
|
508
|
+
...(mode !== undefined ? { mode: choice('mode', mode, MODES) } : {}),
|
|
509
|
+
reproducible: v.flag('reproducible'),
|
|
510
|
+
compute: !v.flag('no-compute'),
|
|
511
|
+
allowEmpty: v.flag('allow-empty'),
|
|
512
|
+
exitCode: v.flag('exit-code'),
|
|
513
|
+
limit:
|
|
514
|
+
limit === undefined
|
|
515
|
+
? DEFAULT_LIMIT
|
|
516
|
+
: parseNumber('limit', limit, 0, Number.MAX_SAFE_INTEGER, true),
|
|
517
|
+
};
|
|
518
|
+
checkOutputs(options);
|
|
519
|
+
return { kind: 'diff', options };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** At most one output may go to stdout (`--stat` and `--name-status` combine). */
|
|
523
|
+
function checkOutputs(o: DiffCommandOptions): void {
|
|
524
|
+
const toStdout = [
|
|
525
|
+
o.json === STDIO ? '--json' : undefined,
|
|
526
|
+
o.delta === STDIO ? '--delta' : undefined,
|
|
527
|
+
o.markdown === STDIO ? '--markdown' : undefined,
|
|
528
|
+
o.stat || o.nameStatus ? (o.stat ? '--stat' : '--name-status') : undefined,
|
|
529
|
+
].filter((x): x is string => x !== undefined);
|
|
530
|
+
if (toStdout.length > 1) {
|
|
531
|
+
throw usageError(
|
|
532
|
+
`${toStdout.slice(0, -1).join(', ')} and ${toStdout.at(-1)} would all write to stdout.`,
|
|
533
|
+
`Send all but one to a file, e.g. ${toStdout[0]} out.${toStdout[0] === '--markdown' ? 'md' : 'json'}.`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (o.html === STDIO) {
|
|
537
|
+
throw usageError('The HTML report must be written to a file.', 'Use -o report.html.');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function parseApply(argv: readonly string[]): Command {
|
|
542
|
+
const raw = rawParse(argv, APPLY_OPTIONS, 2);
|
|
543
|
+
const v = new Values(raw);
|
|
544
|
+
const common = parseCommon(v);
|
|
545
|
+
if (v.flag('help')) return { kind: 'help', topic: 'apply', color: common.color };
|
|
546
|
+
if (v.flag('version')) return { kind: 'version' };
|
|
547
|
+
const [graph, delta] = checkPositionals(raw.positionals, ['graph', 'delta']);
|
|
548
|
+
const languages = parseLanguages(v);
|
|
549
|
+
return {
|
|
550
|
+
kind: 'apply',
|
|
551
|
+
options: {
|
|
552
|
+
...common,
|
|
553
|
+
graph,
|
|
554
|
+
delta,
|
|
555
|
+
output: v.string('output') ?? STDIO,
|
|
556
|
+
strict: !v.flag('no-strict'),
|
|
557
|
+
format: parseFormat('format', v.string('format')) ?? 'auto',
|
|
558
|
+
...(languages ? { languages } : {}),
|
|
559
|
+
pretty: v.flag('pretty'),
|
|
560
|
+
allowEmpty: v.flag('allow-empty'),
|
|
561
|
+
verbose: v.flag('verbose'),
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Parse `argv` (without `node` and the script) into a command.
|
|
568
|
+
*
|
|
569
|
+
* @throws CliError `usage` with a message and a hint for any invalid command line.
|
|
570
|
+
*/
|
|
571
|
+
export function parseCommandLine(argv: readonly string[]): Command {
|
|
572
|
+
const [first, ...rest] = argv;
|
|
573
|
+
if (first === 'apply') return parseApply(rest);
|
|
574
|
+
if (first === 'help') {
|
|
575
|
+
// `kgdiff help [apply] [--color <when>]`
|
|
576
|
+
const raw = rawParse(rest, { color: COMMON.color as OptionSpec }, 0);
|
|
577
|
+
const topic = raw.positionals.join(' ');
|
|
578
|
+
if (raw.positionals.length > 1 || (topic !== '' && topic !== 'apply')) {
|
|
579
|
+
throw usageError(
|
|
580
|
+
`Unknown help topic "${topic}".`,
|
|
581
|
+
'Try `kgdiff help` or `kgdiff help apply`.',
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
kind: 'help',
|
|
586
|
+
topic: topic === 'apply' ? 'apply' : 'main',
|
|
587
|
+
color: parseCommon(new Values(raw)).color,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return parseDiff(argv);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Best-effort `--color` value for printing an error about the command line itself. */
|
|
594
|
+
export function peekColorMode(argv: readonly string[]): ColorMode {
|
|
595
|
+
for (let i = argv.length - 1; i >= 0; i--) {
|
|
596
|
+
const arg = argv[i] as string;
|
|
597
|
+
const value = arg.startsWith('--color=')
|
|
598
|
+
? arg.slice(8)
|
|
599
|
+
: arg === '--color'
|
|
600
|
+
? argv[i + 1]
|
|
601
|
+
: undefined;
|
|
602
|
+
if (value === 'always' || value === 'never' || value === 'auto') return value;
|
|
603
|
+
}
|
|
604
|
+
return 'auto';
|
|
605
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from './main.js';
|
|
3
|
+
import { nodeIO } from './node-io.js';
|
|
4
|
+
|
|
5
|
+
// `kgdiff … | head` closes the pipe early: stop quietly instead of crashing on EPIPE.
|
|
6
|
+
process.stdout.on('error', (error: NodeJS.ErrnoException) => {
|
|
7
|
+
if (error.code === 'EPIPE') process.exit(process.exitCode ?? 0);
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
main(process.argv.slice(2), nodeIO()).then(
|
|
12
|
+
(code) => {
|
|
13
|
+
process.exitCode = code;
|
|
14
|
+
},
|
|
15
|
+
(error: unknown) => {
|
|
16
|
+
// main never rejects; this is a last line of defence.
|
|
17
|
+
process.stderr.write(
|
|
18
|
+
`kgdiff: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
|
19
|
+
);
|
|
20
|
+
process.exitCode = 2;
|
|
21
|
+
},
|
|
22
|
+
);
|