All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.springframework.shell.command.CommandParser Maven / Gradle / Ivy

/*
 * Copyright 2022 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.springframework.shell.command;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springframework.core.ResolvableType;
import org.springframework.core.convert.ConversionService;
import org.springframework.shell.Utils;
import org.springframework.util.StringUtils;

/**
 * Interface parsing arguments for a {@link CommandRegistration}. A command is
 * always identified by a set of words like
 * {@code command subcommand1 subcommand2} and remaining part of it are options
 * which this interface intercepts and translates into format we can understand.
 *
 * @author Janne Valkealahti
 */
public interface CommandParser {

	/**
	 * Result of a parsing {@link CommandOption} with an argument.
	 */
	interface CommandParserResult {

		/**
		 * Gets the {@link CommandOption}.
		 *
		 * @return the command option
		 */
		CommandOption option();

		/**
		 * Gets the value.
		 *
		 * @return the value
		 */
		Object value();

		/**
		 * Gets an instance of a default {@link CommandParserResult}.
		 *
		 * @param option the command option
		 * @param value the value
		 * @return a result
		 */
		static CommandParserResult of(CommandOption option, Object value) {
			return new DefaultCommandParserResult(option, value);
		}
	}

	/**
	 * Results of a {@link CommandParser}. Basically contains a list of {@link CommandParserResult}s.
	 */
	interface CommandParserResults {

		/**
		 * Gets the results.
		 *
		 * @return the results
		 */
		List results();

		/**
		 * Gets the unmapped positional arguments.
		 *
		 * @return the unmapped positional arguments
		 */
		List positional();

		/**
		 * Gets parsing errors.
		 *
		 * @return the parsing errors
		 */
		List errors();

		/**
		 * Gets an instance of a default {@link CommandParserResults}.
		 *
		 * @param results the results
		 * @param positional the list of positional arguments
		 * @param errors the parsing errors
		 * @return a new instance of results
		 */
		static CommandParserResults of(List results, List positional, List errors) {
			return new DefaultCommandParserResults(results, positional, errors);
		}
	}

	/**
	 * Parse options with a given arguments.
	 *
	 * May throw various runtime exceptions depending how parser is configure.
	 * For example if required option is missing an exception is thrown.
	 *
	 * @param options the command options
	 * @param args the arguments
	 * @return parsed results
	 */
	CommandParserResults parse(List options, String[] args);

	/**
	 * Gets an instance of a default command parser.
	 *
	 * @return instance of a default command parser
	 */
	static CommandParser of() {
		return of(null);
	}

	/**
	 * Gets an instance of a default command parser.
	 *
	 * @param conversionService the conversion service
	 * @return instance of a default command parser
	 */
	static CommandParser of(ConversionService conversionService) {
		return new DefaultCommandParser(conversionService);
	}

	/**
	 * Default implementation of a {@link CommandParserResults}.
	 */
	static class DefaultCommandParserResults implements CommandParserResults {

		private List results;
		private List positional;
		private List errors;

		DefaultCommandParserResults(List results, List positional, List errors) {
			this.results = results;
			this.positional = positional;
			this.errors = errors;
		}

		@Override
		public List results() {
			return results;
		}

		@Override
		public List positional() {
			return positional;
		}

		@Override
		public List errors() {
			return errors;
		}
	}

	/**
	 * Default implementation of a {@link CommandParserResult}.
	 */
	static class DefaultCommandParserResult implements CommandParserResult {

		private CommandOption option;
		private Object value;

		DefaultCommandParserResult(CommandOption option, Object value) {
			this.option = option;
			this.value = value;
		}

		@Override
		public CommandOption option() {
			return option;
		}

		@Override
		public Object value() {
			return value;
		}
	}

	/**
	 * Default implementation of a {@link CommandParser}.
	 */
	static class DefaultCommandParser implements CommandParser {

		private final ConversionService conversionService;

		DefaultCommandParser(ConversionService conversionService) {
			this.conversionService = conversionService;
		}

		@Override
		public CommandParserResults parse(List options, String[] args) {
			List requiredOptions = options.stream()
				.filter(o -> o.isRequired())
				.collect(Collectors.toList());

			Lexer lexer = new Lexer(args);
			List> lexerResults = lexer.visit();
			Parser parser = new Parser();
			ParserResults parserResults = parser.visit(lexerResults, options);

			List results = new ArrayList<>();
			List positional = new ArrayList<>();
			List errors = new ArrayList<>();
			parserResults.results.stream().forEach(pr -> {
				if (pr.option != null) {
					results.add(new DefaultCommandParserResult(pr.option, pr.value));
					requiredOptions.remove(pr.option);
				}
				else {
					positional.addAll(pr.args);
				}
				if (pr.error != null) {
					errors.add(pr.error);
				}
			});

			Deque queue = new ArrayDeque<>(parserResults.results);
			options.stream()
				.filter(o -> o.getPosition() > -1)
				.sorted(Comparator.comparingInt(o -> o.getPosition()))
				.forEach(o -> {
					int arityMin = o.getArityMin();
					int arityMax = o.getArityMax();
					List oargs = new ArrayList<>();
					if (arityMin > -1) {
						for (int i = 0; i < arityMax; i++) {
							ParserResult pop = null;
							if (!queue.isEmpty()) {
								pop = queue.pop();
							}
							else {
								break;
							}
							if (pop != null && pop.option == null) {
								if (!pop.args.isEmpty()) {
									oargs.add(pop.args.stream().collect(Collectors.joining(" ")));
								}
							}
						}
					}
					if (!oargs.isEmpty()) {
						results.add(new DefaultCommandParserResult(o, oargs.stream().collect(Collectors.joining(" "))));
						requiredOptions.remove(o);
					}
				});

			requiredOptions.stream().forEach(o -> {
				String ln = o.getLongNames() != null ? Stream.of(o.getLongNames()).collect(Collectors.joining(",")) : "";
				String sn = o.getShortNames() != null ? Stream.of(o.getShortNames()).map(n -> Character.toString(n))
						.collect(Collectors.joining(",")) : "";
				errors.add(MissingOptionException
						.of(String.format("Missing option, longnames='%s', shortnames='%s'", ln, sn), o));
			});

			return new DefaultCommandParserResults(results, positional, errors);
		}

		private static class ParserResult {
			private CommandOption option;
			private List args;
			private Object value;
			private CommandParserException error;

			private ParserResult(CommandOption option, List args, Object value, CommandParserException error) {
				this.option = option;
				this.args = args;
				this.value = value;
				this.error = error;
			}

			static ParserResult of(CommandOption option, List args, Object value,
					CommandParserException error) {
				return new ParserResult(option, args, value, error);
			}
		}

		private static class ParserResults {
			private List results;

			private ParserResults(List results) {
				this.results = results;
			}

			static ParserResults of(List results) {
				return new ParserResults(results);
			}
 		}

		/**
		 * Parser works on a results from a lexer. It looks for given options
		 * and builds parsing results.
		 */
		private class Parser {
			ParserResults visit(List> lexerResults, List options) {
				List results = lexerResults.stream()
					.flatMap(lr -> {
						List option = matchOptions(options, lr.get(0));
						if (option.isEmpty()) {
							return lr.stream().map(a -> ParserResult.of(null, Arrays.asList(a), null, null));
						}
						else {
							return option.stream().flatMap(o -> {
								List subArgs = lr.subList(1, lr.size());
								ConvertArgumentsHolder holder = convertArguments(o, subArgs);
								Object value = holder.value;
								Stream unmapped = holder.unmapped.stream()
									.map(um -> ParserResult.of(null, Arrays.asList(um), null, null));
								Stream res = Stream.of(ParserResult.of(o, subArgs, value, null));
								return Stream.concat(res, unmapped);
							});
						}
					})
					.collect(Collectors.toList());

				// Check options which didn't get matched and add parser result
				// for those having default value.
				List defaultValueOptionsToCheck = new ArrayList<>(options);
				results.stream()
					.forEach(pr -> {
						if (pr.option != null) {
							defaultValueOptionsToCheck.remove(pr.option);
						}
					});
					defaultValueOptionsToCheck.stream()
					.filter(co -> co.getDefaultValue() != null)
					.forEach(co -> {
						Object value = co.getDefaultValue();
						if (conversionService != null && co.getType() != null) {
							if (conversionService.canConvert(co.getDefaultValue().getClass(), co.getType().getRawClass())) {
								value = conversionService.convert(co.getDefaultValue(), co.getType().getRawClass());
							}
						}
						results.add(ParserResult.of(co, Collections.emptyList(), value, null));
					});
				return ParserResults.of(results);
			}

			private List matchOptions(List options, String arg) {
				List matched = new ArrayList<>();
				String trimmed = StringUtils.trimLeadingCharacter(arg, '-');
				int count = arg.length() - trimmed.length();
				if (count == 1) {
					if (trimmed.length() == 1) {
						Character trimmedChar = trimmed.charAt(0);
						options.stream()
							.filter(o -> {
								for (Character sn : o.getShortNames()) {
									if (trimmedChar.equals(sn)) {
										return true;
									}
								}
								return false;
							})
						.findFirst()
						.ifPresent(o -> matched.add(o));
					}
					else if (trimmed.length() > 1) {
						trimmed.chars().mapToObj(i -> (char)i)
							.forEach(c -> {
								options.stream().forEach(o -> {
									for (Character sn : o.getShortNames()) {
										if (c.equals(sn)) {
											matched.add(o);
										}
									}
								});
							});
					}
				}
				else if (count == 2) {
					options.stream()
						.filter(o -> {
							for (String ln : o.getLongNames()) {
								if (trimmed.equals(ln)) {
									return true;
								}
							}
							return false;
						})
						.findFirst()
						.ifPresent(o -> matched.add(o));
				}
				return matched;
			}

			private ConvertArgumentsHolder convertArguments(CommandOption option, List arguments) {
				Object value = null;
				List unmapped = new ArrayList<>();

				ResolvableType type = option.getType();
				int arityMin = option.getArityMin();
				int arityMax = option.getArityMax();

				if (arityMin < 0 && type != null) {
					if (type.isAssignableFrom(boolean.class)) {
						arityMin = 1;
						arityMax = 1;
					}
				}

				if (type != null && type.isAssignableFrom(boolean.class)) {
					if (arguments.size() == 0) {
						value = true;
					}
					else {
						value = Boolean.parseBoolean(arguments.get(0));
					}
				}
				else if (type != null && type.isArray()) {
					value = arguments.stream().collect(Collectors.toList()).toArray();
				}
				else {
					if (!arguments.isEmpty()) {
						if (arguments.size() == 1) {
							value = arguments.get(0);
						}
						else {
							if (arityMax > 0) {
								int limit = Math.min(arguments.size(), arityMax);
								value = arguments.stream().limit(limit).collect(Collectors.joining(" "));
								unmapped.addAll(arguments.subList(limit, arguments.size()));
							}
							else {
								value = arguments.get(0);
								unmapped.addAll(arguments.subList(1, arguments.size()));
							}
						}
					}
				}

				return new ConvertArgumentsHolder(value, unmapped);
			}

			private class ConvertArgumentsHolder {
				Object value;
				final List unmapped = new ArrayList<>();

				ConvertArgumentsHolder(Object value, List unmapped) {
					this.value = value;
					if (unmapped != null) {
						this.unmapped.addAll(unmapped);
					}
				}
			}
		}

		/**
		 * Lexers only responsibility is to splice arguments array into
		 * chunks which belongs together what comes for option structure.
		 */
		private static class Lexer {
			private final String[] args;
			Lexer(String[] args) {
				this.args = args;
			}
			List> visit() {
				return Utils.split(args, t -> t.startsWith("-"));
			}
		}
	}

	static class CommandParserException extends RuntimeException {

		public CommandParserException(String message) {
			super(message);
		}

		public CommandParserException(String message, Throwable cause) {
			super(message, cause);
		}

		public static CommandParserException of(String message) {
			return new CommandParserException(message);
		}
	}

	static class MissingOptionException extends CommandParserException {

		private CommandOption option;

		public MissingOptionException(String message, CommandOption option) {
			super(message);
			this.option = option;
		}

		public static MissingOptionException of(String message, CommandOption option) {
			return new MissingOptionException(message, option);
		}

		public CommandOption getOption() {
			return option;
		}
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy