invoket 0.1.7 → 0.1.9

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/src/parser.ts ADDED
@@ -0,0 +1,601 @@
1
+ // Supported parameter types
2
+ export type ParamType = "string" | "number" | "boolean" | "object" | "array";
3
+
4
+ // Flag metadata for a parameter
5
+ export interface FlagMeta {
6
+ long: string; // e.g., "--name"
7
+ short?: string; // e.g., "-n"
8
+ aliases?: string[]; // e.g., ["--environment"]
9
+ }
10
+
11
+ // Parameter metadata extracted from TypeScript
12
+ export interface ParamMeta {
13
+ name: string;
14
+ type: ParamType;
15
+ required: boolean;
16
+ isRest: boolean;
17
+ flag?: FlagMeta;
18
+ }
19
+
20
+ export interface TaskMeta {
21
+ description: string;
22
+ params: ParamMeta[];
23
+ }
24
+
25
+ // Parsed CLI arguments
26
+ export interface ParsedArgs {
27
+ positional: string[];
28
+ flags: Map<string, string | boolean>;
29
+ }
30
+
31
+ export interface DiscoveredTasks {
32
+ root: Map<string, TaskMeta>;
33
+ namespaced: Map<string, Map<string, TaskMeta>>; // namespace -> method -> meta
34
+ classDoc: string | null;
35
+ }
36
+
37
+ // Extract class-level JSDoc for Tasks class
38
+ export function extractClassDoc(source: string): string | null {
39
+ const match = source.match(
40
+ /\/\*\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*export\s+class\s+Tasks/,
41
+ );
42
+ if (!match) return null;
43
+
44
+ const lines = match[1]
45
+ .split("\n")
46
+ .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
47
+ .filter((line) => line && !line.startsWith("@"));
48
+
49
+ return lines[0] || null;
50
+ }
51
+
52
+ // Parse command to extract namespace and method
53
+ export function parseCommand(command: string): {
54
+ namespace: string | null;
55
+ method: string;
56
+ } {
57
+ const colonIdx = command.indexOf(":");
58
+ const dotIdx = command.indexOf(".");
59
+
60
+ let sepIdx = -1;
61
+ if (colonIdx !== -1 && dotIdx !== -1) {
62
+ sepIdx = Math.min(colonIdx, dotIdx);
63
+ } else if (colonIdx !== -1) {
64
+ sepIdx = colonIdx;
65
+ } else if (dotIdx !== -1) {
66
+ sepIdx = dotIdx;
67
+ }
68
+
69
+ if (sepIdx !== -1) {
70
+ return {
71
+ namespace: command.slice(0, sepIdx),
72
+ method: command.slice(sepIdx + 1),
73
+ };
74
+ }
75
+
76
+ return { namespace: null, method: command };
77
+ }
78
+
79
+ // Extract methods from a class definition in source
80
+ export function extractMethodsFromClass(
81
+ source: string,
82
+ className: string,
83
+ ): Map<string, TaskMeta> {
84
+ const methods = new Map<string, TaskMeta>();
85
+
86
+ // Find the class body
87
+ const classPattern = new RegExp(
88
+ `class\\s+${className}\\s*(?:extends\\s+\\w+)?\\s*\\{([\\s\\S]*?)\\n\\}`,
89
+ );
90
+ const classMatch = source.match(classPattern);
91
+ if (!classMatch) return methods;
92
+
93
+ const classBody = classMatch[1];
94
+
95
+ // Match method declarations with JSDoc
96
+ const methodPattern =
97
+ /\/\*\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*async\s+(\w+)\s*\(\s*c\s*:\s*Context\s*(?:,\s*([^)]+))?\s*\)/g;
98
+
99
+ let match;
100
+ while ((match = methodPattern.exec(classBody)) !== null) {
101
+ const [, jsdoc, methodName, paramsStr] = match;
102
+
103
+ // Skip private methods and constructor
104
+ if (methodName.startsWith("_") || methodName === "constructor") {
105
+ continue;
106
+ }
107
+
108
+ const description =
109
+ jsdoc
110
+ .split("\n")
111
+ .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
112
+ .filter((line) => line && !line.startsWith("@"))[0] || "";
113
+
114
+ const params = parseParams(paramsStr, jsdoc);
115
+ methods.set(methodName, { description, params });
116
+ }
117
+
118
+ return methods;
119
+ }
120
+
121
+ // Extract @flag annotations from JSDoc
122
+ export function extractFlagAnnotations(
123
+ jsdoc: string,
124
+ ): Map<string, { short?: string; aliases?: string[] }> {
125
+ const flags = new Map<string, { short?: string; aliases?: string[] }>();
126
+
127
+ // Match @flag paramName -s --alias1 --alias2
128
+ const flagPattern = /@flag\s+(\w+)\s+([^\n@]*)/g;
129
+ let match;
130
+
131
+ while ((match = flagPattern.exec(jsdoc)) !== null) {
132
+ const [, paramName, flagsStr] = match;
133
+ const parts = flagsStr.trim().split(/\s+/);
134
+
135
+ let short: string | undefined;
136
+ const aliases: string[] = [];
137
+
138
+ for (const part of parts) {
139
+ if (part.startsWith("--")) {
140
+ aliases.push(part);
141
+ } else if (part.startsWith("-") && part.length === 2) {
142
+ short = part;
143
+ }
144
+ }
145
+
146
+ flags.set(paramName, {
147
+ short: short,
148
+ aliases: aliases.length > 0 ? aliases : undefined,
149
+ });
150
+ }
151
+
152
+ return flags;
153
+ }
154
+
155
+ // Parse parameter string into ParamMeta array
156
+ export function parseParams(
157
+ paramsStr: string | undefined,
158
+ jsdoc: string = "",
159
+ ): ParamMeta[] {
160
+ const params: ParamMeta[] = [];
161
+ if (!paramsStr) return params;
162
+
163
+ const flagAnnotations = extractFlagAnnotations(jsdoc);
164
+
165
+ // Check for rest parameter first: ...name: type
166
+ const restMatch = paramsStr.match(/\.\.\.(\w+)\s*:\s*(\w+\[\]|\w+)/);
167
+ if (restMatch) {
168
+ const [, name, rawType] = restMatch;
169
+ params.push({
170
+ name,
171
+ type: rawType.endsWith("[]") ? "array" : "string",
172
+ required: false,
173
+ isRest: true,
174
+ // Rest params don't get flags
175
+ });
176
+ return params;
177
+ }
178
+
179
+ // Updated regex to handle union types with null (e.g., string | null)
180
+ const paramPattern =
181
+ /(\w+)\s*:\s*(\w+\[\]|Record<[^>]+>|\{[^}]*\}|string|number|boolean|\w+)(?:\s*\|\s*null)?(?:\s*=\s*[^,)]+)?/g;
182
+ let paramMatch;
183
+
184
+ while ((paramMatch = paramPattern.exec(paramsStr)) !== null) {
185
+ const [fullMatch, name, rawType] = paramMatch;
186
+ const hasDefault = fullMatch.includes("=");
187
+ const isNullable = fullMatch.includes("| null");
188
+
189
+ let type: ParamType;
190
+ if (rawType === "string") {
191
+ type = "string";
192
+ } else if (rawType === "number") {
193
+ type = "number";
194
+ } else if (rawType === "boolean") {
195
+ type = "boolean";
196
+ } else if (rawType.endsWith("[]")) {
197
+ type = "array";
198
+ } else {
199
+ type = "object";
200
+ }
201
+
202
+ // Build flag metadata
203
+ const annotation = flagAnnotations.get(name);
204
+ const flag: FlagMeta = {
205
+ long: `--${name}`,
206
+ short: annotation?.short,
207
+ aliases: annotation?.aliases,
208
+ };
209
+
210
+ params.push({
211
+ name,
212
+ type,
213
+ required: !hasDefault && !isNullable,
214
+ isRest: false,
215
+ flag,
216
+ });
217
+ }
218
+
219
+ return params;
220
+ }
221
+
222
+ // Extract import statements: maps imported identifiers to their module paths
223
+ export function extractImports(
224
+ source: string,
225
+ ): Map<string, string> {
226
+ const imports = new Map<string, string>();
227
+ const pattern =
228
+ /import\s+(?!\s*type\s)(?:\{([^}]+)\}|(\w+))\s+from\s+["']([^"']+)["']/g;
229
+ let match;
230
+ while ((match = pattern.exec(source)) !== null) {
231
+ const [, namedImports, defaultImport, importPath] = match;
232
+ if (namedImports) {
233
+ for (const spec of namedImports.split(",")) {
234
+ const parts = spec.trim().split(/\s+as\s+/);
235
+ const localName = parts[parts.length - 1].trim();
236
+ if (localName) imports.set(localName, importPath);
237
+ }
238
+ }
239
+ if (defaultImport) {
240
+ imports.set(defaultImport, importPath);
241
+ }
242
+ }
243
+ return imports;
244
+ }
245
+
246
+ // Find namespace assignments whose class wasn't found in the local source
247
+ export function findUnresolvedNamespaces(
248
+ source: string,
249
+ discovered: DiscoveredTasks,
250
+ ): Map<string, string> {
251
+ const unresolved = new Map<string, string>(); // propName -> className
252
+ const nsPattern = /(\w+)\s*=\s*new\s+(\w+)\s*\(\s*\)/g;
253
+ let match;
254
+ while ((match = nsPattern.exec(source)) !== null) {
255
+ const [, propName, className] = match;
256
+ if (propName.startsWith("_")) continue;
257
+ if (discovered.namespaced.has(propName)) continue;
258
+ unresolved.set(propName, className);
259
+ }
260
+ return unresolved;
261
+ }
262
+
263
+ // Discover all tasks including namespaced ones (source parsing only)
264
+ export function discoverAllTasks(source: string): DiscoveredTasks {
265
+ const root = extractMethodsFromClass(source, "Tasks");
266
+ const namespaced = new Map<string, Map<string, TaskMeta>>();
267
+ const classDoc = extractClassDoc(source);
268
+
269
+ // Find namespace assignments in Tasks class: propertyName = new ClassName()
270
+ const nsPattern = /(\w+)\s*=\s*new\s+(\w+)\s*\(\s*\)/g;
271
+ let nsMatch;
272
+
273
+ while ((nsMatch = nsPattern.exec(source)) !== null) {
274
+ const [, propName, className] = nsMatch;
275
+
276
+ // Skip private namespaces
277
+ if (propName.startsWith("_")) continue;
278
+
279
+ const nsMethods = extractMethodsFromClass(source, className);
280
+ if (nsMethods.size > 0) {
281
+ namespaced.set(propName, nsMethods);
282
+ }
283
+ }
284
+
285
+ return { root, namespaced, classDoc };
286
+ }
287
+
288
+ // Discover methods from runtime instance (for imported namespaces)
289
+ export function discoverRuntimeNamespaces(
290
+ instance: any,
291
+ discovered: DiscoveredTasks,
292
+ ): void {
293
+ // Find namespace properties on the instance
294
+ for (const propName of Object.getOwnPropertyNames(instance)) {
295
+ // Skip private, already discovered, or non-objects
296
+ if (propName.startsWith("_")) continue;
297
+ if (discovered.namespaced.has(propName)) continue;
298
+
299
+ const prop = instance[propName];
300
+ if (!prop || typeof prop !== "object" || Array.isArray(prop)) continue;
301
+
302
+ // Discover methods from this namespace at runtime
303
+ const methods = new Map<string, TaskMeta>();
304
+ let proto = Object.getPrototypeOf(prop);
305
+
306
+ while (proto && proto !== Object.prototype) {
307
+ for (const methodName of Object.getOwnPropertyNames(proto)) {
308
+ if (
309
+ methodName === "constructor" ||
310
+ methodName.startsWith("_") ||
311
+ typeof prop[methodName] !== "function"
312
+ ) {
313
+ continue;
314
+ }
315
+
316
+ // No type info for imported methods - treat args as strings
317
+ if (!methods.has(methodName)) {
318
+ methods.set(methodName, { description: "", params: [] });
319
+ }
320
+ }
321
+ proto = Object.getPrototypeOf(proto);
322
+ }
323
+
324
+ if (methods.size > 0) {
325
+ discovered.namespaced.set(propName, methods);
326
+ }
327
+ }
328
+ }
329
+
330
+ // Convert CLI arg to typed value
331
+ export function coerceArg(value: string, type: ParamType): unknown {
332
+ switch (type) {
333
+ case "number": {
334
+ if (value === "") {
335
+ throw new Error(`Expected number, got ""`);
336
+ }
337
+ const n = Number(value);
338
+ if (Number.isNaN(n)) {
339
+ throw new Error(`Expected number, got "${value}"`);
340
+ }
341
+ return n;
342
+ }
343
+ case "boolean":
344
+ if (value === "true" || value === "1") return true;
345
+ if (value === "false" || value === "0") return false;
346
+ throw new Error(`Expected boolean, got "${value}"`);
347
+ case "object":
348
+ case "array": {
349
+ try {
350
+ const parsed = JSON.parse(value);
351
+ if (type === "array" && !Array.isArray(parsed)) {
352
+ throw new Error(`Expected array, got ${typeof parsed}`);
353
+ }
354
+ if (
355
+ type === "object" &&
356
+ (typeof parsed !== "object" ||
357
+ Array.isArray(parsed) ||
358
+ parsed === null)
359
+ ) {
360
+ throw new Error(
361
+ `Expected object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`,
362
+ );
363
+ }
364
+ return parsed;
365
+ } catch (e) {
366
+ if (e instanceof SyntaxError) {
367
+ throw new Error(`Invalid JSON: ${e.message}`);
368
+ }
369
+ throw e;
370
+ }
371
+ }
372
+ case "string":
373
+ default:
374
+ return value;
375
+ }
376
+ }
377
+
378
+ // Parse CLI arguments into flags and positional args
379
+ export function parseCliArgs(args: string[]): ParsedArgs {
380
+ const positional: string[] = [];
381
+ const flags = new Map<string, string | boolean>();
382
+ let stopFlagParsing = false;
383
+
384
+ for (let i = 0; i < args.length; i++) {
385
+ const arg = args[i];
386
+
387
+ if (stopFlagParsing) {
388
+ positional.push(arg);
389
+ continue;
390
+ }
391
+
392
+ if (arg === "--") {
393
+ stopFlagParsing = true;
394
+ continue;
395
+ }
396
+
397
+ // --flag=value
398
+ if (arg.startsWith("--") && arg.includes("=")) {
399
+ const eqIdx = arg.indexOf("=");
400
+ const name = arg.slice(2, eqIdx);
401
+ const value = arg.slice(eqIdx + 1);
402
+ flags.set(name, value);
403
+ continue;
404
+ }
405
+
406
+ // --no-flag (boolean negation)
407
+ if (arg.startsWith("--no-")) {
408
+ const name = arg.slice(5);
409
+ flags.set(name, false);
410
+ continue;
411
+ }
412
+
413
+ // --flag (may be boolean or need next arg)
414
+ if (arg.startsWith("--")) {
415
+ const name = arg.slice(2);
416
+ const nextArg = args[i + 1];
417
+
418
+ // If next arg exists and doesn't look like a flag, use it as value
419
+ if (nextArg !== undefined && !nextArg.startsWith("-")) {
420
+ flags.set(name, nextArg);
421
+ i++; // Skip next arg
422
+ } else {
423
+ flags.set(name, true); // Boolean flag
424
+ }
425
+ continue;
426
+ }
427
+
428
+ // -f=value (short with equals, single char only)
429
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("=")) {
430
+ const eqIdx = arg.indexOf("=");
431
+ const name = arg.slice(1, eqIdx);
432
+ if (name.length === 1) {
433
+ const value = arg.slice(eqIdx + 1);
434
+ flags.set(name, value);
435
+ continue;
436
+ }
437
+ }
438
+
439
+ // -f value or -f (boolean) — single char short flags only
440
+ if (arg.startsWith("-") && arg.length === 2) {
441
+ const name = arg.slice(1);
442
+ const nextArg = args[i + 1];
443
+
444
+ if (nextArg !== undefined && !nextArg.startsWith("-")) {
445
+ flags.set(name, nextArg);
446
+ i++;
447
+ } else {
448
+ flags.set(name, true);
449
+ }
450
+ continue;
451
+ }
452
+
453
+ // Positional argument
454
+ positional.push(arg);
455
+ }
456
+
457
+ return { positional, flags };
458
+ }
459
+
460
+ // Resolve arguments from parsed CLI args using param metadata
461
+ export function resolveArgs(params: ParamMeta[], parsed: ParsedArgs): unknown[] {
462
+ const result: unknown[] = [];
463
+ const usedPositional = new Set<number>();
464
+
465
+ for (const param of params) {
466
+ // Handle rest parameters - collect all remaining positional args
467
+ if (param.isRest) {
468
+ const remaining = parsed.positional.filter(
469
+ (_, i) => !usedPositional.has(i),
470
+ );
471
+ result.push(...remaining);
472
+ break;
473
+ }
474
+
475
+ let value: string | boolean | undefined;
476
+
477
+ // Try to get value from flags first
478
+ if (param.flag) {
479
+ // Check long flag (without --)
480
+ const longName = param.flag.long.slice(2);
481
+ if (parsed.flags.has(longName)) {
482
+ value = parsed.flags.get(longName);
483
+ }
484
+ // Check short flag (without -)
485
+ else if (param.flag.short) {
486
+ const shortName = param.flag.short.slice(1);
487
+ if (parsed.flags.has(shortName)) {
488
+ value = parsed.flags.get(shortName);
489
+ }
490
+ }
491
+ // Check aliases
492
+ if (value === undefined && param.flag.aliases) {
493
+ for (const alias of param.flag.aliases) {
494
+ const aliasName = alias.slice(2);
495
+ if (parsed.flags.has(aliasName)) {
496
+ value = parsed.flags.get(aliasName);
497
+ break;
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ // Fall back to positional if no flag found
504
+ if (value === undefined) {
505
+ for (let i = 0; i < parsed.positional.length; i++) {
506
+ if (!usedPositional.has(i)) {
507
+ value = parsed.positional[i];
508
+ usedPositional.add(i);
509
+ break;
510
+ }
511
+ }
512
+ }
513
+
514
+ // Handle missing values
515
+ if (value === undefined) {
516
+ if (param.required) {
517
+ throw new Error(
518
+ `Missing required argument: <${param.name}> (${param.type})`,
519
+ );
520
+ }
521
+ result.push(undefined); // Preserve position so subsequent params align correctly
522
+ continue;
523
+ }
524
+
525
+ // Coerce and add to result
526
+ // Boolean flags that are already boolean don't need coercion
527
+ if (typeof value === "boolean" && param.type === "boolean") {
528
+ result.push(value);
529
+ } else {
530
+ result.push(coerceArg(String(value), param.type));
531
+ }
532
+ }
533
+
534
+ return result;
535
+ }
536
+
537
+ // Format param for help display
538
+ export function formatParam(param: ParamMeta): string {
539
+ if (param.isRest) {
540
+ return `[${param.name}...]`;
541
+ }
542
+ return param.required ? `<${param.name}>` : `[${param.name}]`;
543
+ }
544
+
545
+ // Format flag info for display
546
+ export function formatFlagInfo(param: ParamMeta): string {
547
+ if (!param.flag || param.isRest) return "";
548
+
549
+ const parts: string[] = [param.flag.long];
550
+ if (param.flag.short) {
551
+ parts.push(param.flag.short);
552
+ }
553
+ if (param.flag.aliases) {
554
+ parts.push(...param.flag.aliases);
555
+ }
556
+ return parts.join(", ");
557
+ }
558
+
559
+ // Display task listing (used by both help and --list)
560
+ export function printTaskList(discovered: DiscoveredTasks): void {
561
+ for (const [name, meta] of discovered.root) {
562
+ const paramStr = meta.params.map(formatParam).join(" ");
563
+ const signature = paramStr ? `${name} ${paramStr}` : name;
564
+ console.log(` ${signature}`);
565
+ }
566
+ for (const [ns, methods] of discovered.namespaced) {
567
+ console.log(`\n${ns}:`);
568
+ for (const [name, meta] of methods) {
569
+ const paramStr = meta.params.map(formatParam).join(" ");
570
+ const signature = paramStr
571
+ ? `${ns}:${name} ${paramStr}`
572
+ : `${ns}:${name}`;
573
+ console.log(` ${signature}`);
574
+ }
575
+ }
576
+ }
577
+
578
+ // Display help for a specific task
579
+ export function showTaskHelp(command: string, meta: TaskMeta): void {
580
+ const paramStr = meta.params.map(formatParam).join(" ");
581
+ const signature = paramStr ? `${command} ${paramStr}` : command;
582
+
583
+ console.log(`Usage: invt ${signature}\n`);
584
+
585
+ if (meta.description) {
586
+ console.log(`${meta.description}\n`);
587
+ }
588
+
589
+ if (meta.params.length > 0) {
590
+ console.log("Arguments:");
591
+ for (const param of meta.params) {
592
+ const reqStr = param.required ? "(required)" : "(optional)";
593
+ const typeStr = param.isRest ? `${param.type}...` : param.type;
594
+ const flagStr = formatFlagInfo(param);
595
+ const flagDisplay = flagStr ? ` ${flagStr}` : "";
596
+ console.log(
597
+ ` ${param.name.padEnd(15)} ${typeStr.padEnd(10)} ${reqStr}${flagDisplay}`,
598
+ );
599
+ }
600
+ }
601
+ }