blob: 4f00923b599fb43d9d18a034d28080cb55b36dd0 [file] [log] [blame]
import 'package:args/args.dart';
import 'util.dart';
/*
* TODO: an interesting scenario: if there is only one subcommand,
* then tabbing into an app just completes to that one command. Weird?
*/
List<String> getArgsCompletions(
ArgParser parser,
List<String> providedArgs,
String compLine,
int compPoint,
) {
assert(parser != null);
assert(providedArgs != null);
// all arg entries: no empty items, no null items, all pre-trimmed
for (var i = 0; i < providedArgs.length; i++) {
final arg = providedArgs[i];
final msg = 'Arg at index $i with value "$arg" ';
if (arg.trim() != arg) throw StateError('$msg has whitespace');
if (i < (providedArgs.length - 1)) {
if (arg.isEmpty) {
throw StateError('$msg – Only the last arg can be an empty string');
}
}
}
void sublog(Object obj) {
log(obj, Tag.getArgsCompletions);
}
sublog('provided args: ${helpfulToString(providedArgs)}');
sublog('COMP_LINE: "$compLine"');
sublog('COMP_POINT: $compPoint');
if (compPoint < compLine.length) {
// TODO: ponder smart ways to handle in-line completion
sublog('cursor is in the middle of the line. NO-OP');
return const [];
}
if (providedArgs.isEmpty) {
sublog('empty args. Complete with all available commands');
return parser.commands.keys.toList();
}
final alignedArgsOptions =
providedArgs.map((arg) => _getOptionForArg(parser, arg)).toList();
/*
* NOTE: nuanced behavior
* If the last item provided is a full, real item (command or option)
* It should be completed with its full name so the user can move on
* Soooo....we are excluding the last item in [alignedArgsOptions] from
* optionsDefinedInArgs
*
* Keep in mind, if we're already on to the next item to complete, the last
* item is likely empty string '' or '--', so this isn't a problem
*/
// a set of options in use (minus, potentially, the last one)
// all non-null, all unique
final optionsDefinedInArgs = alignedArgsOptions
.take(alignedArgsOptions.length - 1)
.where((o) => o != null)
.toSet();
sublog('defined options: ${optionsDefinedInArgs.map((o) => o.name).toSet()}');
final parserOptionCompletions = List<String>.unmodifiable(
_parserOptionCompletions(parser, optionsDefinedInArgs));
/*
* KNOWN: at least one item in providedArgs last and first are now safe
*/
/*
* Now we're going to lean on the existing parse functionality to see
* if the provided args (or a subset of them) parse to valid [ArgsResult]
* If it does, we can use the result to determine what we should do next
*/
final subsetTuple = _validSubset(parser, providedArgs);
final validSubSet = subsetTuple.subset;
final subsetResult = subsetTuple.result;
sublog('valid subset: ${helpfulToString(validSubSet)}');
/*
* CASE: we have a command
* get recursive
*/
if (subsetResult != null && subsetResult.command != null) {
// get all of the args *after* the command name
// call in recursively with the sub command parser, right?
final subCommand = subsetResult.command;
final subCommandIndex = providedArgs.indexOf(subCommand.name);
assert(subCommandIndex >= 0);
sublog('so, it seems we have command "${subCommand.name}" at '
'index $subCommandIndex');
final subCommandParser = parser.commands[subCommand.name];
final subCommandArgs = providedArgs.sublist(subCommandIndex + 1);
/*
* only start rockin' the sub command parser if
* 1) there's a start on sub args
* 2) there's whitespace at the end of compLine
*/
if (subCommandArgs.isNotEmpty || compLine.endsWith(' ')) {
return getArgsCompletions(
subCommandParser, subCommandArgs, compLine, compPoint);
}
}
final removedItems = providedArgs.sublist(validSubSet.length);
assert(removedItems.length + validSubSet.length == providedArgs.length);
sublog('removed items: ${helpfulToString(removedItems)}');
final lastArg = providedArgs.last;
/*
* CASE: one removed item, that looks like a partial option
* try to match it against available options
*/
if (removedItems.length == 1 && removedItems.single.startsWith('--')) {
final removedItem = removedItems.single;
if (compLine.endsWith(' ')) {
// if the removed item maps to an option w/ allowed values
// we should return those values to complete against
final option = alignedArgsOptions[providedArgs.length - 1];
if (option != null &&
option.allowed != null &&
option.allowed.isNotEmpty) {
assert(!option.isFlag);
sublog('completing all allowed value for option "${option.name}"');
return option.allowed.toList();
}
} else {
sublog('completing the name of options starting with "$removedItem"');
return parserOptionCompletions
.where((String option) => option.startsWith(removedItem))
.toList();
}
}
/*
* CASE: second-to-last arg is an option+allowed and lastArg is empty
* then we should complete with the available options, right?
*/
if (providedArgs.length >= 2) {
final option = alignedArgsOptions[providedArgs.length - 2];
if (option != null) {
if (option.allowed != null && option.allowed.isNotEmpty) {
assert(!option.isFlag);
sublog('completing option "${option.name}"');
final optionValue = providedArgs[providedArgs.length - 1];
return option.allowed
.where((String v) => v.startsWith(optionValue))
.toList();
} else if (!option.isFlag) {
sublog('not providing completions. Wating for option value');
return const [];
}
}
}
/*
* CASE: no removed items and compLine ends in a space ->
* do command completion
*/
if (removedItems.isEmpty && lastArg == '') {
sublog('doing command completion');
return parser.commands.keys.toList();
}
/*
* CASE: If we have '--', then let's naively complete all options
*/
if (lastArg == '--') {
sublog('Completing with all available options.');
return parserOptionCompletions;
}
/*
* CASE: a partial command name?
* if the last arg doesn't start with a '-'
*/
if (!lastArg.startsWith('-')) {
// for now, let's pretend this is partial command
sublog('completing command names that start with "$lastArg"');
return parser.commands.keys
.where((String commandName) => commandName.startsWith(lastArg))
.toList();
}
/*
* CASE: the last argument is valid, so we should return it
* if types the last char of a valid option, hitting tab should complete it
*/
if (lastArg != '' && parserOptionCompletions.contains(lastArg)) {
sublog('completing final arg');
return [lastArg];
}
sublog('Exhausted options. No suggestions.');
return const [];
}
Option _getOptionForArg(ArgParser parser, String arg) {
// could be a full arg name
if (arg.startsWith('--')) {
final nameOption = arg.substring(2);
final option = parser.options[nameOption];
if (option != null) {
return option;
}
}
// could be a 'not' arg name
if (arg.startsWith('--no-')) {
final nameOption = arg.substring(5);
final option = parser.options[nameOption];
if (option != null && option.negatable) {
return option;
}
}
if (arg.startsWith('-') && arg.length == 2) {
// all abbreviations are single-character
final abbr = arg.substring(1);
assert(abbr.length == 1);
return parser.findByAbbreviation(abbr);
}
// no matching option
return null;
}
Iterable<String> _parserOptionCompletions(
ArgParser parser, Set<Option> existingOptions) {
assert(
existingOptions.every((option) => parser.options.containsValue(option)));
return parser.options.values
.where((opt) =>
!existingOptions.contains(opt) || opt.type == OptionType.multiple)
.expand(_argsOptionCompletions);
}
_Tuple _validSubset(ArgParser parser, List<String> providedArgs) {
/* start with all of the args, loop through parsing them,
* removing one every time
*
* Util:
* 1) we have a valid ArgsResult
* 2) we have no more args
*/
final validSubSet = providedArgs.toList();
ArgResults subsetResult;
while (validSubSet.isNotEmpty) {
try {
subsetResult = parser.parse(validSubSet);
break;
} on FormatException catch (_) {
//_log('tried to parse subset $validSubSet');
//_log('error:\t$ex');
// I guess that won't parse
}
// TODO: other ways this could fail? Hmm...
validSubSet.removeLast();
}
return _Tuple(validSubSet, subsetResult);
}
List<String> _argsOptionCompletions(Option option) => <String>[
'--${option.name}',
if (option.negatable) '--no-${option.name}',
]..sort();
class _Tuple {
final List<String> subset;
final ArgResults result;
const _Tuple(this.subset, this.result);
}