Skip to content

Commit cace5fd

Browse files
committed
Support for macros and escaping flag values
1 parent 2ea5ea1 commit cace5fd

4 files changed

Lines changed: 137 additions & 11 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ if (cli.flagOn.noSummary)
3333
console.info('You supplied', cli.params.length , 'CLI parameter(s).');
3434
```
3535
For a real world example, see:
36-
[cli.js](https://github.com/center-key/copy-file-util/blob/main/bin/cli.js)
36+
[copy-file.ts](https://github.com/center-key/copy-file-util/blob/main/src/copy-file.ts)
3737

3838
If your CLI tool is named `my-program` and a user runs it like:
3939
```shell
@@ -45,6 +45,9 @@ the resulting `cli` object will be:
4545
flagMap: {
4646
cd: 'src',
4747
},
48+
flagMapRaw: {
49+
cd: 'src',
50+
},
4851
flagOn: {
4952
cd: true,
5053
find: false,
@@ -58,6 +61,10 @@ the resulting `cli` object will be:
5861
> [!NOTE]
5962
> _Single quotes in commands are normalized so they work cross-platform and avoid the errors often encountered on Microsoft Windows._
6063
64+
> [!NOTE]
65+
> _CLI flag values support escaped charcters and macros._<br>
66+
> _For documentation, see:_ https://github.com/center-key/replacer-util
67+
6168
## C) Results
6269
The `cliArgvUtil.parse()` returns an object of type `Result`:
6370
```typescript

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
"node": true,
3535
"mocha": true
3636
},
37+
"cliConfig": {
38+
"macros": {
39+
"lucky-number": "777"
40+
}
41+
},
3742
"runScriptsConfig": {
3843
"clean": [
3944
"rimraf build dist"
@@ -67,6 +72,6 @@
6772
"rimraf": "~6.1",
6873
"run-scripts-util": "~1.3",
6974
"typescript": "~5.9",
70-
"typescript-eslint": "~8.52"
75+
"typescript-eslint": "~8.54"
7176
}
7277
}

spec/mocha.spec.js

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,15 @@ describe('Library module', () => {
3636
const module = cliArgvUtil;
3737
const actual = Object.keys(module).sort().map(key => [key, typeof module[key]]);
3838
const expected = [
39-
['calcAncestor', 'function'],
40-
['cleanPath', 'function'],
41-
['parse', 'function'],
42-
['readFolder', 'function'],
43-
['run', 'function'],
44-
['unquoteArgs', 'function'],
39+
['assert', 'function'],
40+
['calcAncestor', 'function'],
41+
['cleanPath', 'function'],
42+
['parse', 'function'],
43+
['readFolder', 'function'],
44+
['readPackageJson', 'function'],
45+
['run', 'function'],
46+
['unescape', 'function'],
47+
['unquoteArgs', 'function'],
4548
];
4649
assertDeepStrictEqual(actual, expected);
4750
});
@@ -60,6 +63,36 @@ describe('Calling cliArgvUtil.parse()', () => {
6063
flag1: undefined,
6164
flag3: 'three',
6265
},
66+
flagMapRaw: {
67+
flag1: undefined,
68+
flag3: 'three',
69+
},
70+
flagOn: {
71+
flag1: true,
72+
flag2: false,
73+
flag3: true,
74+
},
75+
invalidFlag: null,
76+
invalidFlagMsg: null,
77+
params: ['file.html', 'file.png'],
78+
paramCount: 2,
79+
};
80+
assertDeepStrictEqual(actual, expected);
81+
});
82+
83+
it('with escaped characters and macros results in the correct replacements', () => {
84+
const validFlags = ['flag1', 'flag2', 'flag3'];
85+
mockCli('file.html --flag1={{hash}}{{space}}Allow{{space}}bots{{bang}} file.png --flag3={{macro:lucky-number}}');
86+
const actual = cliArgvUtil.parse(validFlags);
87+
const expected = {
88+
flagMap: {
89+
flag1: '# Allow bots!',
90+
flag3: '777',
91+
},
92+
flagMapRaw: {
93+
flag1: '{{hash}}{{space}}Allow{{space}}bots{{bang}}',
94+
flag3: '{{macro:lucky-number}}',
95+
},
6396
flagOn: {
6497
flag1: true,
6598
flag2: false,
@@ -82,6 +115,10 @@ describe('Calling cliArgvUtil.parse()', () => {
82115
flagOne: undefined,
83116
flagThree: 'three',
84117
},
118+
flagMapRaw: {
119+
flagOne: undefined,
120+
flagThree: 'three',
121+
},
85122
flagOn: {
86123
flagOne: true,
87124
flagTwo: false,
@@ -101,6 +138,7 @@ describe('Calling cliArgvUtil.parse()', () => {
101138
const actual = cliArgvUtil.parse(validFlags);
102139
const expected = {
103140
flagMap: {},
141+
flagMapRaw: {},
104142
flagOn: {
105143
flag1: false,
106144
flag2: false,
@@ -123,6 +161,10 @@ describe('Calling cliArgvUtil.parse()', () => {
123161
flagOne: undefined,
124162
flagThree: 't h r e e',
125163
},
164+
flagMapRaw: {
165+
flagOne: undefined,
166+
flagThree: 't h r e e',
167+
},
126168
flagOn: {
127169
flagOne: true,
128170
flagTwo: false,
@@ -150,6 +192,10 @@ describe('Correct error message is generated', () => {
150192
bogus: undefined,
151193
flag3: 'three',
152194
},
195+
flagMapRaw: {
196+
bogus: undefined,
197+
flag3: 'three',
198+
},
153199
flagOn: {
154200
flag1: false,
155201
flag2: false,

src/cli-argv-util.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,83 @@ export type Ancestor = {
1919
message: string, //color output in the format: "common: source -> target"
2020
};
2121
export type Result = {
22-
flagMap: StringFlagMap, //map of flag values for each user supplied flag
22+
flagMap: StringFlagMap, //map of unescaped flag values for each user supplied flag
23+
flagMapRaw: StringFlagMap, //map of flag values for each user supplied flag
2324
flagOn: BooleanFlagMap, //map of the enabled status for all valid flags
2425
invalidFlag: string | null, //name of the first invalid flag
2526
invalidFlagMsg: string | null, //error message for the invalid flag
26-
params: string[], //array of parameter values supplied by the user
2727
paramCount: number, //number of parameters supplied by the user
28+
params: string[], //array of parameter values supplied by the user
2829
};
30+
type Json = string | number | boolean | null | undefined | JsonObject | Json[];
31+
type JsonObject = { [key: string]: Json };
2932

3033
const cliArgvUtil = {
3134

35+
assert(ok: unknown, message: string | null) {
36+
if (!ok)
37+
throw new Error(`[replacer-util] ${message}`);
38+
},
39+
40+
readPackageJson() {
41+
// Returns package.json as an object literal.
42+
const pkgExists = fs.existsSync('package.json');
43+
const pkg = pkgExists ? <JsonObject>JSON.parse(fs.readFileSync('package.json', 'utf-8')) : null;
44+
const fixHiddenKeys = (pkgObj: JsonObject) => {
45+
const unhide = (key: string) => {
46+
const newKey = key.replace(/[@./]/g, '-');
47+
if (!pkgObj[newKey])
48+
pkgObj[newKey] = pkgObj[key]!;
49+
};
50+
Object.keys(pkgObj).forEach(unhide);
51+
};
52+
if (pkg?.dependencies)
53+
fixHiddenKeys(<JsonObject>pkg.dependencies);
54+
if (pkg?.devDependencies)
55+
fixHiddenKeys(<JsonObject>pkg.devDependencies);
56+
return pkg;
57+
},
58+
59+
unescape(flags: StringFlagMap): StringFlagMap {
60+
// Returns a map of CLI flags with all the flag values unescaped and macros expanded.
61+
// Example:
62+
// '{{hash}}{{space}}Allow{{space}}bots{{bang}}' --> '# Allow bots!'
63+
const escapers: [RegExp, string][] = [
64+
[/{{apos}}/g, "'"],
65+
[/{{bang}}/g, '!'],
66+
[/{{close-curly}}/g, '}'],
67+
[/{{equals}}/g, '='],
68+
[/{{gt}}/g, '>'],
69+
[/{{hash}}/g, '#'],
70+
[/{{lt}}/g, '<'],
71+
[/{{open-curly}}/g, '{'],
72+
[/{{pipe}}/g, '|'],
73+
[/{{quote}}/g, '"'],
74+
[/{{semi}}/g, ';'],
75+
[/{{space}}/g, ' '],
76+
];
77+
const macroPattern = /^{{macro:(.*)}}$/;
78+
const flagEntries = Object.entries(flags);
79+
const usesMacros = flagEntries.some(entry => entry[1]?.match(macroPattern));
80+
const pkg = usesMacros ? cliArgvUtil.readPackageJson()! : {};
81+
if (!pkg.cliConfig && pkg.replacerConfig) //DEPRECATED
82+
pkg.cliConfig = pkg.replacerConfig; //backwards compatibility workaround
83+
const macros = <JsonObject | undefined>(<JsonObject | undefined>pkg.cliConfig)?.macros;
84+
const unescapeOne = (flagValue: string, escaper: typeof escapers[number]) =>
85+
flagValue.replace(escaper[0], escaper[1]);
86+
const expandMacro = (flagValue: string) => {
87+
// If param is a macro defined in package.json, return the macro's value.
88+
const macroName = <keyof JsonObject>flagValue.match(macroPattern)?.[1];
89+
const macroValue = <string>macros?.[macroName];
90+
const missing = macroName && !macroValue;
91+
cliArgvUtil.assert(!missing, `Macro "${macroName}" used but not defined in package.json`);
92+
return macroName ? macroValue : flagValue;
93+
};
94+
const doReplacements = (flagValue?: string) =>
95+
!flagValue ? undefined : escapers.reduce(unescapeOne, expandMacro(flagValue));
96+
return Object.fromEntries(flagEntries.map(pair => [pair[0], doReplacements(pair[1])]));
97+
},
98+
3299
parse(validFlags: string[]): Result {
33100
const toCamel = (token: string) => token.replace(/-./g, char => char[1]!.toUpperCase()); //example: 'no-map' --> 'noMap'
34101
const toEntry = (pair: string[]) => [toCamel(pair[0]!), pair[1]]; //example: ['no-map'] --> ['noMap', undefined]
@@ -42,7 +109,8 @@ const cliArgvUtil = {
42109
const helpMsg = '\nValid flags are --' + validFlags.join(' --');
43110
const params = args.filter(arg => !/^--/.test(arg));
44111
return {
45-
flagMap: flagMap,
112+
flagMap: cliArgvUtil.unescape(flagMap),
113+
flagMapRaw: flagMap,
46114
flagOn: flagOn,
47115
invalidFlag: invalidFlag,
48116
invalidFlagMsg: invalidFlag ? 'Invalid flag: --' + invalidFlag + helpMsg : null,

0 commit comments

Comments
 (0)