Skip to content

Commit ef3695d

Browse files
committed
feat: restrict non multi options (#44)
1 parent 3458c64 commit ef3695d

File tree

6 files changed

+278
-60
lines changed

6 files changed

+278
-60
lines changed

example/test/integration/completion_integration_test.dart

Lines changed: 102 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -419,42 +419,6 @@ void main() {
419419
suggests(noSuggestions),
420420
);
421421
});
422-
423-
test(
424-
'suggest all options when previous option is continuous with a value',
425-
() async {
426-
await expectLater(
427-
'example_cli some_command --continuous="yeahoo" ',
428-
suggests(allOptionsInThisLevel),
429-
);
430-
},
431-
);
432-
433-
test(
434-
'suggest all options when previous option is continuous with a value',
435-
() async {
436-
await expectLater(
437-
'example_cli some_command --continuous yeahoo ',
438-
suggests(allOptionsInThisLevel),
439-
);
440-
},
441-
);
442-
});
443-
444-
group('flags', () {
445-
test('suggest all options when a flag was declared', () async {
446-
await expectLater(
447-
'example_cli some_command --flag ',
448-
suggests(allOptionsInThisLevel),
449-
);
450-
});
451-
452-
test('suggest all options when a negated flag was declared', () async {
453-
await expectLater(
454-
'example_cli some_command --no-flag ',
455-
suggests(allOptionsInThisLevel),
456-
);
457-
});
458422
});
459423
});
460424

@@ -532,15 +496,6 @@ void main() {
532496
);
533497
});
534498
});
535-
536-
group('flag', () {
537-
test('suggest all options when a flag was declared', () async {
538-
await expectLater(
539-
'example_cli some_command -f ',
540-
suggests(allOptionsInThisLevel),
541-
);
542-
});
543-
});
544499
});
545500

546501
group('invalid options', () {
@@ -552,15 +507,109 @@ void main() {
552507
});
553508
});
554509

555-
group(
556-
'repeating options',
557-
tags: 'known-issues',
558-
() {
559-
group('non multi options', () {});
510+
group('repeating options', () {
511+
group('non multi options', () {
512+
test('do not include option after it is specified', () async {
513+
await expectLater(
514+
'example_cli some_command --discrete foo ',
515+
suggests(allOptionsInThisLevel.except('--discrete')),
516+
);
517+
});
518+
519+
test('do not include abbr option after it is specified', () async {
520+
await expectLater(
521+
'example_cli some_command --discrete foo -',
522+
suggests(allAbbreviationsInThisLevel.except('-d')),
523+
);
524+
});
525+
526+
test('do not include option after it is specified as abbr', () async {
527+
await expectLater(
528+
'example_cli some_command -d foo ',
529+
suggests(allOptionsInThisLevel.except('--discrete')),
530+
);
531+
});
532+
533+
test(
534+
'do not include option after it is specified as joined abbr',
535+
() async {
536+
await expectLater(
537+
'example_cli some_command -dfoo ',
538+
suggests(allOptionsInThisLevel.except('--discrete')),
539+
);
540+
},
541+
tags: 'known-issues',
542+
);
543+
544+
test('do not include flag after it is specified', () async {
545+
await expectLater(
546+
'example_cli some_command --flag ',
547+
suggests(allOptionsInThisLevel.except('--flag')),
548+
);
549+
});
550+
551+
test('do not include flag after it is specified (abbr)', () async {
552+
await expectLater(
553+
'example_cli some_command -f ',
554+
suggests(allOptionsInThisLevel.except('--flag')),
555+
);
556+
});
560557

561-
group('multi options', () {});
562-
},
563-
);
558+
test('do not include negated flag after it is specified', () async {
559+
await expectLater(
560+
'example_cli some_command --no-flag ',
561+
suggests(allOptionsInThisLevel.except('--flag')),
562+
);
563+
});
564+
565+
test('do not regard negation of non negatable flag', () async {
566+
await expectLater(
567+
'example_cli some_command --no-trueflag ',
568+
suggests(allOptionsInThisLevel),
569+
);
570+
});
571+
});
572+
573+
group('multi options', () {
574+
test('include multi option after it is specified', () async {
575+
await expectLater(
576+
'example_cli some_command --multi-c yeahoo ',
577+
suggests(allOptionsInThisLevel),
578+
);
579+
});
580+
581+
test('include multi option after it is specified (abbr)', () async {
582+
await expectLater(
583+
'example_cli some_command -n yeahoo ',
584+
suggests(allOptionsInThisLevel),
585+
);
586+
});
587+
588+
test(
589+
'include option after it is specified (abbr joined)',
590+
() async {
591+
await expectLater(
592+
'example_cli some_command -nyeahoo ',
593+
suggests(allOptionsInThisLevel),
594+
);
595+
},
596+
tags: 'known-issues',
597+
);
598+
599+
test('include discrete multi option value after it is specified',
600+
() async {
601+
await expectLater(
602+
'example_cli some_command --multi-d bar -m ',
603+
suggests({
604+
'fii': 'fii help',
605+
'bar': 'bar help',
606+
'fee': 'fee help',
607+
'i have space': 'an allowed option with space on it'
608+
}),
609+
);
610+
});
611+
});
612+
});
564613
});
565614

566615
group('some_other_command', () {

example/test/integration/utils.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,9 @@ Future<Map<String, String?>> runCompletionCommand(
8888

8989
return map;
9090
}
91+
92+
extension CompletionUtils on Map<String, String?> {
93+
Map<String, String?> except(String key) {
94+
return Map.from(this)..remove(key);
95+
}
96+
}

lib/src/parser/arg_parser_extension.dart

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ bool isAbbr(String string) => _abbrRegex.hasMatch(string);
1616
/// Extends [ArgParser] with utility methods that allow parsing a completion
1717
/// input, which in most cases only regards part of the rules.
1818
extension ArgParserExtension on ArgParser {
19+
/// Tries to parse the minimal subset of valid [args] as valid options.
20+
ArgResults? findValidOptions(List<String> args) {
21+
final loosenOptionsGramamar = _looseOptions();
22+
var currentArgs = args;
23+
while (currentArgs.isNotEmpty) {
24+
try {
25+
return loosenOptionsGramamar.parse(currentArgs);
26+
} catch (_) {
27+
currentArgs = currentArgs.take(currentArgs.length - 1).toList();
28+
}
29+
}
30+
return null;
31+
}
32+
1933
/// Parses [args] with this [ArgParser]'s command structure only, ignore
2034
/// option strict rules (mandatory, allowed values, non negatable flags,
2135
/// default values, etc);
@@ -25,7 +39,7 @@ extension ArgParserExtension on ArgParser {
2539
/// Returns null if there is an error when parsing, which means the given args
2640
/// do not respect the known command structure.
2741
ArgResults? tryParseCommandsOnly(Iterable<String> args) {
28-
final commandsOnlyGrammar = _looseOptions();
42+
final commandsOnlyGrammar = _cloneCommandsOnly();
2943

3044
final filteredArgs = args.where((element) {
3145
return !isAbbr(element) && !isOption(element) && element.isNotEmpty;
@@ -40,16 +54,58 @@ extension ArgParserExtension on ArgParser {
4054
}
4155

4256
/// Recursively copies this [ArgParser] without options.
43-
ArgParser _looseOptions() {
57+
ArgParser _cloneCommandsOnly() {
4458
final clonedArgParser = ArgParser(
4559
allowTrailingOptions: allowTrailingOptions,
4660
);
4761

4862
for (final entry in commands.entries) {
49-
final parser = entry.value._looseOptions();
63+
final parser = entry.value._cloneCommandsOnly();
5064
clonedArgParser.addCommand(entry.key, parser);
5165
}
5266

5367
return clonedArgParser;
5468
}
69+
70+
/// Copies this [ArgParser] with a less strict option mapping.
71+
///
72+
/// It preserves only the options names, types, abbreviations and aliases.
73+
///
74+
/// It disregard subcommands.
75+
ArgParser _looseOptions() {
76+
final clonedArgParser = ArgParser(
77+
allowTrailingOptions: allowTrailingOptions,
78+
);
79+
80+
for (final entry in options.entries) {
81+
final option = entry.value;
82+
83+
if (option.isFlag) {
84+
clonedArgParser.addFlag(
85+
option.name,
86+
abbr: option.abbr,
87+
aliases: option.aliases,
88+
negatable: option.negatable ?? true,
89+
);
90+
}
91+
92+
if (option.isSingle) {
93+
clonedArgParser.addOption(
94+
option.name,
95+
abbr: option.abbr,
96+
aliases: option.aliases,
97+
);
98+
}
99+
100+
if (option.isMultiple) {
101+
clonedArgParser.addMultiOption(
102+
option.name,
103+
abbr: option.abbr,
104+
aliases: option.aliases,
105+
);
106+
}
107+
}
108+
109+
return clonedArgParser;
110+
}
55111
}

lib/src/parser/completion_level.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class CompletionLevel {
1818
@visibleForTesting
1919
const CompletionLevel({
2020
required this.grammar,
21+
this.parsedOptions,
2122
required this.rawArgs,
2223
required this.visibleSubcommands,
2324
required this.visibleOptions,
@@ -90,17 +91,24 @@ class CompletionLevel {
9091
rawArgs = rootArgs.toList();
9192
}
9293

94+
final validOptionsResult = originalGrammar.findValidOptions(rawArgs);
95+
9396
final visibleSubcommands = subcommands?.values.where((command) {
9497
return !command.hidden;
9598
}).toList() ??
9699
[];
97100

98101
final visibleOptions = originalGrammar.options.values.where((option) {
102+
final wasParsed = validOptionsResult?.wasParsed(option.name) ?? false;
103+
if (wasParsed) {
104+
return option.isMultiple;
105+
}
99106
return !option.hide;
100107
}).toList();
101108

102109
return CompletionLevel(
103110
grammar: originalGrammar,
111+
parsedOptions: validOptionsResult,
104112
rawArgs: rawArgs,
105113
visibleSubcommands: visibleSubcommands,
106114
visibleOptions: visibleOptions,
@@ -111,6 +119,10 @@ class CompletionLevel {
111119
/// needs completion.
112120
final ArgParser grammar;
113121

122+
/// An [ArgResults] that includes the valid options passed to the command on
123+
/// completion level. Null if no valid options were passed.
124+
final ArgResults? parsedOptions;
125+
114126
/// The user input that needs completion starting from the
115127
/// command/sub_command being completed.
116128
final List<String> rawArgs;

0 commit comments

Comments
 (0)