// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:collection'; import 'arg_parser.dart'; import 'arg_parser_exception.dart'; import 'arg_results.dart'; import 'option.dart'; /// The actual argument parsing class. /// /// Unlike [ArgParser] which is really more an "arg grammar", this is the class /// that does the parsing and holds the mutable state required during a parse. class Parser { /// If parser is parsing a command's options, this will be the name of the /// command. For top-level results, this returns `null`. final String? _commandName; /// The parser for the supercommand of this command parser, or `null` if this /// is the top-level parser. final Parser? _parent; /// The grammar being parsed. final ArgParser _grammar; /// The arguments being parsed. final Queue _args; /// The remaining non-option, non-command arguments. final List _rest; /// The accumulated parsed options. final Map _results = {}; Parser(this._commandName, this._grammar, this._args, [this._parent, List? rest]) : _rest = [...?rest]; /// The current argument being parsed. String get _current => _args.first; /// Parses the arguments. This can only be called once. ArgResults parse() { var arguments = _args.toList(); if (_grammar.allowsAnything) { return newArgResults( _grammar, const {}, _commandName, null, arguments, arguments); } ArgResults? commandResults; // Parse the args. while (_args.isNotEmpty) { if (_current == '--') { // Reached the argument terminator, so stop here. _args.removeFirst(); break; } // Try to parse the current argument as a command. This happens before // options so that commands can have option-like names. var command = _grammar.commands[_current]; if (command != null) { _validate(_rest.isEmpty, 'Cannot specify arguments before a command.', _current); var commandName = _args.removeFirst(); var commandParser = Parser(commandName, command, _args, this, _rest); try { commandResults = commandParser.parse(); } on ArgParserException catch (error) { throw ArgParserException( error.message, [commandName, ...error.commands], error.argumentName, error.source, error.offset); } // All remaining arguments were passed to command so clear them here. _rest.clear(); break; } // Try to parse the current argument as an option. Note that the order // here matters. if (_parseSoloOption()) continue; if (_parseAbbreviation(this)) continue; if (_parseLongOption()) continue; // This argument is neither option nor command, so stop parsing unless // the [allowTrailingOptions] option is set. if (!_grammar.allowTrailingOptions) break; _rest.add(_args.removeFirst()); } // Check if mandatory and invoke existing callbacks. _grammar.options.forEach((name, option) { var parsedOption = _results[name]; var callback = option.callback; if (callback == null) return; // Check if an option is mandatory and was passed; if not, throw an // exception. if (option.mandatory && parsedOption == null) { throw ArgParserException('Option $name is mandatory.', null, name); } // ignore: avoid_dynamic_calls callback(option.valueOrDefault(parsedOption)); }); // Add in the leftover arguments we didn't parse to the innermost command. _rest.addAll(_args); _args.clear(); return newArgResults( _grammar, _results, _commandName, commandResults, _rest, arguments); } /// Pulls the value for [option] from the second argument in [_args]. /// /// Validates that there is a valid value there. void _readNextArgAsValue(Option option, String arg) { // Take the option argument from the next command line arg. _validate(_args.isNotEmpty, 'Missing argument for "$arg".', arg); _setOption(_results, option, _current, arg); _args.removeFirst(); } /// Tries to parse the current argument as a "solo" option, which is a single /// hyphen followed by a single letter. /// /// We treat this differently than collapsed abbreviations (like "-abc") to /// handle the possible value that may follow it. bool _parseSoloOption() { // Hand coded regexp: r'^-([a-zA-Z0-9])$' // Length must be two, hyphen followed by any letter/digit. if (_current.length != 2) return false; if (!_current.startsWith('-')) return false; var opt = _current[1]; if (!_isLetterOrDigit(opt.codeUnitAt(0))) return false; return _handleSoloOption(opt); } bool _handleSoloOption(String opt) { var option = _grammar.findByAbbreviation(opt); if (option == null) { // Walk up to the parent command if possible. _validate(_parent != null, 'Could not find an option or flag "-$opt".', '-$opt'); return _parent!._handleSoloOption(opt); } _args.removeFirst(); if (option.isFlag) { _setFlag(_results, option, true); } else { _readNextArgAsValue(option, '-$opt'); } return true; } /// Tries to parse the current argument as a series of collapsed abbreviations /// (like "-abc") or a single abbreviation with the value directly attached /// to it (like "-mrelease"). bool _parseAbbreviation(Parser innermostCommand) { // Hand coded regexp: r'^-([a-zA-Z0-9]+)(.*)$' // Hyphen then at least one letter/digit then zero or more // anything-but-newlines. if (_current.length < 2) return false; if (!_current.startsWith('-')) return false; // Find where we go from letters/digits to rest. var index = 1; while (index < _current.length && _isLetterOrDigit(_current.codeUnitAt(index))) { ++index; } // Must be at least one letter/digit. if (index == 1) return false; // If the first character is the abbreviation for a non-flag option, then // the rest is the value. var lettersAndDigits = _current.substring(1, index); var rest = _current.substring(index); if (rest.contains('\n') || rest.contains('\r')) return false; return _handleAbbreviation(lettersAndDigits, rest, innermostCommand); } bool _handleAbbreviation( String lettersAndDigits, String rest, Parser innermostCommand) { var c = lettersAndDigits.substring(0, 1); var first = _grammar.findByAbbreviation(c); if (first == null) { // Walk up to the parent command if possible. _validate(_parent != null, 'Could not find an option with short name "-$c".', '-$c'); return _parent! ._handleAbbreviation(lettersAndDigits, rest, innermostCommand); } else if (!first.isFlag) { // The first character is a non-flag option, so the rest must be the // value. var value = '${lettersAndDigits.substring(1)}$rest'; _setOption(_results, first, value, '-$c'); } else { // If we got some non-flag characters, then it must be a value, but // if we got here, it's a flag, which is wrong. _validate( rest == '', 'Option "-$c" is a flag and cannot handle value ' '"${lettersAndDigits.substring(1)}$rest".', '-$c'); // Not an option, so all characters should be flags. // We use "innermostCommand" here so that if a parent command parses the // *first* letter, subcommands can still be found to parse the other // letters. for (var i = 0; i < lettersAndDigits.length; i++) { var c = lettersAndDigits.substring(i, i + 1); innermostCommand._parseShortFlag(c); } } _args.removeFirst(); return true; } void _parseShortFlag(String c) { var option = _grammar.findByAbbreviation(c); if (option == null) { // Walk up to the parent command if possible. _validate(_parent != null, 'Could not find an option with short name "-$c".', '-$c'); _parent!._parseShortFlag(c); return; } // In a list of short options, only the first can be a non-flag. If // we get here we've checked that already. _validate(option.isFlag, 'Option "-$c" must be a flag to be in a collapsed "-".', '-$c'); _setFlag(_results, option, true); } /// Tries to parse the current argument as a long-form named option, which /// may include a value like "--mode=release" or "--mode release". bool _parseLongOption() { // Hand coded regexp: r'^--([a-zA-Z\-_0-9]+)(=(.*))?$' // Two hyphens then at least one letter/digit/hyphen, optionally an equal // sign followed by zero or more anything-but-newlines. if (!_current.startsWith('--')) return false; var index = _current.indexOf('='); var name = index == -1 ? _current.substring(2) : _current.substring(2, index); for (var i = 0; i != name.length; ++i) { if (!_isLetterDigitHyphenOrUnderscore(name.codeUnitAt(i))) return false; } var value = index == -1 ? null : _current.substring(index + 1); if (value != null && (value.contains('\n') || value.contains('\r'))) { return false; } return _handleLongOption(name, value); } bool _handleLongOption(String name, String? value) { var option = _grammar.findByNameOrAlias(name); if (option != null) { _args.removeFirst(); if (option.isFlag) { _validate(value == null, 'Flag option "--$name" should not be given a value.', '--$name'); _setFlag(_results, option, true); } else if (value != null) { // We have a value like --foo=bar. _setOption(_results, option, value, '--$name'); } else { // Option like --foo, so look for the value as the next arg. _readNextArgAsValue(option, '--$name'); } } else if (name.startsWith('no-')) { // See if it's a negated flag. var positiveName = name.substring('no-'.length); option = _grammar.findByNameOrAlias(positiveName); if (option == null) { // Walk up to the parent command if possible. _validate(_parent != null, 'Could not find an option named "--$name".', '--$name'); return _parent!._handleLongOption(name, value); } _args.removeFirst(); _validate( option.isFlag, 'Cannot negate non-flag option "--$name".', '--$name'); _validate( option.negatable!, 'Cannot negate option "--$name".', '--$name'); _setFlag(_results, option, false); } else { // Walk up to the parent command if possible. _validate(_parent != null, 'Could not find an option named "--$name".', '--$name'); return _parent!._handleLongOption(name, value); } return true; } /// Called during parsing to validate the arguments. /// /// Throws an [ArgParserException] if [condition] is `false`. void _validate(bool condition, String message, [String? args, List? source, int? offset]) { if (!condition) { throw ArgParserException(message, null, args, source, offset); } } /// Validates and stores [value] as the value for [option], which must not be /// a flag. void _setOption(Map results, Option option, String value, String arg) { assert(!option.isFlag); if (!option.isMultiple) { _validateAllowed(option, value, arg); results[option.name] = value; return; } var list = results.putIfAbsent(option.name, () => []) as List; if (option.splitCommas) { for (var element in value.split(',')) { _validateAllowed(option, element, arg); list.add(element); } } else { _validateAllowed(option, value, arg); list.add(value); } } /// Validates and stores [value] as the value for [option], which must be a /// flag. void _setFlag(Map results, Option option, bool value) { assert(option.isFlag); results[option.name] = value; } /// Validates that [value] is allowed as a value of [option]. void _validateAllowed(Option option, String value, String arg) { if (option.allowed == null) return; _validate(option.allowed!.contains(value), '"$value" is not an allowed value for option "$arg".', arg); } } bool _isLetterOrDigit(int codeUnit) => // Uppercase letters. (codeUnit >= 65 && codeUnit <= 90) || // Lowercase letters. (codeUnit >= 97 && codeUnit <= 122) || // Digits. (codeUnit >= 48 && codeUnit <= 57); bool _isLetterDigitHyphenOrUnderscore(int codeUnit) => _isLetterOrDigit(codeUnit) || // Hyphen. codeUnit == 45 || // Underscore. codeUnit == 95;