diff --git a/aesh/src/main/java/org/aesh/command/container/CommandContainer.java b/aesh/src/main/java/org/aesh/command/container/CommandContainer.java index 83e9d9cb..7f5d3960 100644 --- a/aesh/src/main/java/org/aesh/command/container/CommandContainer.java +++ b/aesh/src/main/java/org/aesh/command/container/CommandContainer.java @@ -71,6 +71,24 @@ ProcessedCommand, CI> parseAndPopulate(InvocationProviders invocatio AeshContext aeshContext) throws CommandLineParserException, OptionValidatorException; + /** + * Parse and populate the command with CommandContext for parent command injection. + * + * @param invocationProviders providers + * @param aeshContext the aesh context + * @param commandContext the command context for sub-command mode (may be null) + * @return the processed command + * @throws CommandLineParserException on parse error + * @throws OptionValidatorException on validation error + */ + default ProcessedCommand, CI> parseAndPopulate(InvocationProviders invocationProviders, + AeshContext aeshContext, + org.aesh.command.impl.context.CommandContext commandContext) + throws CommandLineParserException, OptionValidatorException { + // Default implementation ignores context for backward compatibility + return parseAndPopulate(invocationProviders, aeshContext); + } + CommandContainerResult executeCommand(ParsedLine line, InvocationProviders invocationProviders, AeshContext aeshContext, CI commandInvocation) diff --git a/aesh/src/main/java/org/aesh/command/container/DefaultCommandContainer.java b/aesh/src/main/java/org/aesh/command/container/DefaultCommandContainer.java index 0be7cdff..0351c965 100644 --- a/aesh/src/main/java/org/aesh/command/container/DefaultCommandContainer.java +++ b/aesh/src/main/java/org/aesh/command/container/DefaultCommandContainer.java @@ -64,6 +64,14 @@ public void emptyLine() { public ProcessedCommand, CI> parseAndPopulate(InvocationProviders invocationProviders, AeshContext aeshContext) throws CommandLineParserException, OptionValidatorException { + return parseAndPopulate(invocationProviders, aeshContext, null); + } + + @Override + public ProcessedCommand, CI> parseAndPopulate(InvocationProviders invocationProviders, + AeshContext aeshContext, + org.aesh.command.impl.context.CommandContext commandContext) + throws CommandLineParserException, OptionValidatorException { if(lines.isEmpty()) return null; ParsedLine aeshLine = lines.poll(); @@ -75,8 +83,14 @@ public ProcessedCommand, CI> parseAndPopulate(InvocationProviders in if (getParser().parsedCommand() == null) { throw new CommandLineParserException("Command and/or sub-command is not valid!"); } - getParser().parsedCommand().getCommandPopulator().populateObject(getParser().parsedCommand().getProcessedCommand(), - invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + // Use context-aware populateObject if CommandContext is available + if (commandContext != null && commandContext.isInSubCommandMode()) { + getParser().parsedCommand().getCommandPopulator().populateObject(getParser().parsedCommand().getProcessedCommand(), + invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE, commandContext); + } else { + getParser().parsedCommand().getCommandPopulator().populateObject(getParser().parsedCommand().getProcessedCommand(), + invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + } return getParser().parsedCommand().getProcessedCommand(); } diff --git a/aesh/src/main/java/org/aesh/command/impl/Executions.java b/aesh/src/main/java/org/aesh/command/impl/Executions.java index 928479ce..381c1643 100644 --- a/aesh/src/main/java/org/aesh/command/impl/Executions.java +++ b/aesh/src/main/java/org/aesh/command/impl/Executions.java @@ -28,10 +28,12 @@ import org.aesh.command.container.CommandContainer; import org.aesh.command.impl.completer.CompleterData; import org.aesh.command.impl.completer.NullOptionCompleter; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.impl.internal.OptionType; import org.aesh.command.impl.internal.ParsedCommand; import org.aesh.command.impl.internal.ProcessedCommand; import org.aesh.command.impl.internal.ProcessedOption; +import org.aesh.command.option.ParentCommand; import org.aesh.command.impl.operator.AndOperator; import org.aesh.command.impl.operator.AppendOutputRedirectionOperator; import org.aesh.command.impl.operator.ConfigurationOperator; @@ -60,6 +62,8 @@ import org.aesh.selector.Selector; import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -115,7 +119,9 @@ public Command getCommand() { @Override public void populateCommand() throws CommandLineParserException, OptionValidatorException { if (!populated) { - cmd = commandContainer.parseAndPopulate(runtime.invocationProviders(), runtime.getAeshContext()); + // Get command context for inherited option injection + CommandContext cmdContext = getCommandInvocation().getCommandContext(); + cmd = commandContainer.parseAndPopulate(runtime.invocationProviders(), runtime.getAeshContext(), cmdContext); populated = true; } } @@ -130,6 +136,13 @@ public CommandResult execute() throws CommandException, InterruptedException, Co CommandLineParserException, OptionValidatorException { //first we need to parse and populate the command line populateCommand(); + + // Inject @ParentCommand fields if in sub-command mode + CommandContext cmdContext = getCommandInvocation().getCommandContext(); + if (cmdContext != null && cmdContext.isInSubCommandMode()) { + injectParentCommands(cmd.getCommand(), cmdContext); + } + //finally we set the command that should be executed executable.setCommand(cmd.getCommand()); @@ -434,4 +447,47 @@ private static Operator buildOperator(OperatorType op, AeshContext context) { } throw new IllegalArgumentException("Unsupported operator " + op); } + + /** + * Inject parent command instances into fields annotated with @ParentCommand. + */ + private static void injectParentCommands(Object command, CommandContext commandContext) { + List fields = getAllFields(command.getClass()); + for (Field field : fields) { + if (field.isAnnotationPresent(ParentCommand.class)) { + Class fieldType = field.getType(); + + // Find matching parent command in context stack + @SuppressWarnings("unchecked") + Command parent = commandContext.getParentCommand( + (Class>) fieldType.asSubclass(Command.class)); + + if (parent != null) { + try { + if (!Modifier.isPublic(field.getModifiers())) { + field.setAccessible(true); + } + field.set(command, parent); + } catch (IllegalAccessException e) { + // Log warning but continue + e.printStackTrace(); + } + } + } + } + } + + /** + * Get all fields from a class and its superclasses. + */ + private static List getAllFields(Class clazz) { + List fields = new ArrayList<>(); + while (clazz != null) { + for (Field field : clazz.getDeclaredFields()) { + fields.add(field); + } + clazz = clazz.getSuperclass(); + } + return fields; + } } diff --git a/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java b/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java index 90102d13..73aa7b75 100644 --- a/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java @@ -191,6 +191,7 @@ private static void processField(ProcessedCommand processedCommand, Field field) .overrideRequired(o.overrideRequired()) .negatable(o.negatable()) .negationPrefix(o.negationPrefix()) + .inherited(o.inherited()) .build() ); } @@ -311,6 +312,7 @@ else if((arg = field.getAnnotation(Argument.class)) != null) { .renderer(arg.renderer()) .parser(arg.parser()) .overrideRequired(arg.overrideRequired()) + .inherited(arg.inherited()) .build() ); } diff --git a/aesh/src/main/java/org/aesh/command/impl/context/CommandContext.java b/aesh/src/main/java/org/aesh/command/impl/context/CommandContext.java new file mode 100644 index 00000000..3569520c --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/impl/context/CommandContext.java @@ -0,0 +1,672 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.impl.context; + +import org.aesh.command.Command; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.internal.ProcessedOption; +import org.aesh.command.impl.parser.CommandLineParser; +import org.aesh.command.settings.SubCommandModeSettings; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tracks the current command context state for sub-command mode. + * Maintains a stack of context frames, each representing a group command + * that has been entered. Provides access to parent command values. + * + * @author Ståle W. Pedersen + */ +public class CommandContext { + private final Deque contextStack; + private final String originalPrompt; + private SubCommandModeSettings settings; + + public CommandContext(String originalPrompt) { + this(originalPrompt, SubCommandModeSettings.defaults()); + } + + public CommandContext(String originalPrompt, SubCommandModeSettings settings) { + this.contextStack = new ArrayDeque<>(); + this.originalPrompt = originalPrompt; + this.settings = settings != null ? settings : SubCommandModeSettings.defaults(); + } + + /** + * Get the sub-command mode settings. + * + * @return the settings + */ + public SubCommandModeSettings getSettings() { + return settings; + } + + /** + * Set the sub-command mode settings. + * + * @param settings the settings + */ + public void setSettings(SubCommandModeSettings settings) { + this.settings = settings != null ? settings : SubCommandModeSettings.defaults(); + } + + /** + * Push a new context frame when entering a group command. + * + * @param parser The group command's parser + * @param command The populated command instance with parsed values + */ + public void push(CommandLineParser parser, Command command) { + contextStack.push(new ContextFrame(parser, command)); + } + + /** + * Pop the current context frame when exiting. + * + * @return the popped context frame + */ + public ContextFrame pop() { + return contextStack.pop(); + } + + /** + * Get the current context frame. + * + * @return the current frame, or null if not in sub-command mode + */ + public ContextFrame current() { + return contextStack.peek(); + } + + /** + * Check if we're in sub-command mode. + * + * @return true if in sub-command mode + */ + public boolean isInSubCommandMode() { + return !contextStack.isEmpty(); + } + + /** + * Get the depth of the context stack. + * + * @return the number of nested contexts + */ + public int depth() { + return contextStack.size(); + } + + /** + * Get the context path as a string (e.g., "module:project"). + * + * @return the context path + */ + public String getContextPath() { + List names = new ArrayList<>(); + for (ContextFrame frame : contextStack) { + names.add(0, frame.getCommandName()); + } + return String.join(settings.getContextSeparator(), names); + } + + /** + * Get the context path with spaces for command prefixing (e.g., "module project"). + * + * @return the context path with spaces + */ + public String getContextPathWithSpaces() { + List names = new ArrayList<>(); + for (ContextFrame frame : contextStack) { + names.add(0, frame.getCommandName()); + } + return String.join(" ", names); + } + + /** + * Build a prompt showing the current context. + * Examples: "module> ", "module[my-module]> ", "module:project> " + * + * @param showArgumentInPrompt whether to show primary argument in prompt + * @return the formatted prompt string + */ + public String buildPrompt(boolean showArgumentInPrompt) { + if (contextStack.isEmpty()) { + return originalPrompt; + } + + StringBuilder prompt = new StringBuilder(); + List frames = new ArrayList<>(contextStack); + Collections.reverse(frames); + + String separator = settings.getContextSeparator(); + boolean showArg = showArgumentInPrompt && settings.showArgumentInPrompt(); + + for (int i = 0; i < frames.size(); i++) { + if (i > 0) { + prompt.append(separator); + } + ContextFrame frame = frames.get(i); + prompt.append(frame.getCommandName()); + + // Show primary argument value in prompt if present and enabled + if (showArg) { + String argValue = frame.getPrimaryArgumentValue(); + if (argValue != null && !argValue.isEmpty()) { + prompt.append("[").append(argValue).append("]"); + } + } + } + prompt.append("> "); + return prompt.toString(); + } + + /** + * Get the original prompt before entering sub-command mode. + * + * @return the original prompt string + */ + public String getOriginalPrompt() { + return originalPrompt; + } + + /** + * Check if the given command is an exit command for sub-command mode. + * + * @param command the command to check + * @return true if the command should exit sub-command mode + */ + public boolean isExitCommand(String command) { + if (command == null || command.isEmpty()) { + return false; + } + String trimmed = command.trim(); + if (settings.getExitCommand() != null && trimmed.equals(settings.getExitCommand())) { + return true; + } + if (settings.getAlternativeExitCommand() != null && trimmed.equals(settings.getAlternativeExitCommand())) { + return true; + } + return false; + } + + /** + * Format the enter message for sub-command mode. + * + * @param commandName the name of the command being entered + * @return the formatted enter message, or null if no message configured + */ + public String formatEnterMessage(String commandName) { + String message = settings.getEnterMessage(); + if (message == null || message.isEmpty()) { + return null; + } + return message.replace("{name}", commandName); + } + + /** + * Format the exit hint for sub-command mode. + * + * @return the formatted exit hint, or null if no hint configured + */ + public String formatExitHint() { + String hint = settings.getExitHint(); + if (hint == null || hint.isEmpty()) { + return null; + } + hint = hint.replace("{exit}", settings.getExitCommand() != null ? settings.getExitCommand() : "exit"); + hint = hint.replace("{alt}", settings.getAlternativeExitCommand() != null ? settings.getAlternativeExitCommand() : ".."); + return hint; + } + + /** + * Format the exit message for sub-command mode. + * + * @param commandName the name of the command being exited + * @return the formatted exit message, or null if no message configured + */ + public String formatExitMessage(String commandName) { + String message = settings.getExitMessage(); + if (message == null || message.isEmpty()) { + return null; + } + return message.replace("{name}", commandName); + } + + // ========== Parent Value Access Methods ========== + + /** + * Get a value from any parent command in the context stack. + * Searches from immediate parent up to root. + * + * @param fieldName The field name to look for + * @param type The expected type + * @param the value type + * @return The value, or null if not found + */ + public T getParentValue(String fieldName, Class type) { + return getParentValue(fieldName, type, null); + } + + /** + * Get a value from any parent command with a default. + * + * @param fieldName The field name to look for + * @param type The expected type + * @param defaultValue The default value if not found + * @param the value type + * @return The value, or defaultValue if not found + */ + public T getParentValue(String fieldName, Class type, T defaultValue) { + for (ContextFrame frame : contextStack) { + Object value = frame.getFieldValue(fieldName); + if (value != null && type.isInstance(value)) { + return type.cast(value); + } + } + return defaultValue; + } + + /** + * Get the immediate parent command instance. + * + * @return the parent command, or null if not in sub-command mode + */ + public Command getParentCommand() { + ContextFrame frame = contextStack.peek(); + return frame != null ? frame.getCommand() : null; + } + + /** + * Get all parent commands from immediate to root. + * + * @return list of parent commands + */ + public List> getParentCommands() { + List> parents = new ArrayList<>(); + for (ContextFrame frame : contextStack) { + parents.add(frame.getCommand()); + } + return parents; + } + + /** + * Get a specific parent command by type. + * + * @param type the command class to find + * @param the command type + * @return the matching parent command, or null if not found + */ + @SuppressWarnings("unchecked") + public > T getParentCommand(Class type) { + for (ContextFrame frame : contextStack) { + if (type.isInstance(frame.getCommand())) { + return (T) frame.getCommand(); + } + } + return null; + } + + // ========== Inherited Value Access Methods ========== + + /** + * Get an inherited value from parent commands. + * Only returns values from options marked with inherited=true. + * + * @param fieldName The field name to look for + * @param type The expected type + * @param the value type + * @return The inherited value, or null if not found + */ + public T getInheritedValue(String fieldName, Class type) { + return getInheritedValue(fieldName, type, null); + } + + /** + * Get an inherited value from parent commands with a default. + * Only returns values from options marked with inherited=true. + * + * @param fieldName The field name to look for + * @param type The expected type + * @param defaultValue The default value if not found + * @param the value type + * @return The inherited value, or defaultValue if not found + */ + public T getInheritedValue(String fieldName, Class type, T defaultValue) { + for (ContextFrame frame : contextStack) { + Object value = frame.getInheritedValue(fieldName); + if (value != null && type.isInstance(value)) { + return type.cast(value); + } + } + return defaultValue; + } + + /** + * Get all inherited options from the context stack. + * Returns a map of field names to their values for all inherited options. + * + * @return map of inherited field names to values + */ + public Map getAllInheritedValues() { + Map inherited = new HashMap<>(); + // Process from root to immediate parent to get correct override order + List frames = new ArrayList<>(contextStack); + Collections.reverse(frames); + for (ContextFrame frame : frames) { + inherited.putAll(frame.getInheritedValues()); + } + return inherited; + } + + /** + * Get all inherited options with their ProcessedOption metadata. + * Returns a map of field names to ProcessedOptions for injection. + * + * @return map of field names to ProcessedOptions + */ + public Map getAllInheritedOptions() { + Map inherited = new HashMap<>(); + // Process from root to immediate parent to get correct override order + List frames = new ArrayList<>(contextStack); + Collections.reverse(frames); + for (ContextFrame frame : frames) { + inherited.putAll(frame.getInheritedOptions()); + } + return inherited; + } + + /** + * Format context values for display. + * + * @return formatted string showing all context values + */ + public String formatContextValues() { + if (contextStack.isEmpty()) { + return "Not in sub-command mode."; + } + + StringBuilder sb = new StringBuilder(); + List frames = new ArrayList<>(contextStack); + Collections.reverse(frames); + + for (ContextFrame frame : frames) { + sb.append("Context: ").append(frame.getCommandName()).append("\n"); + Map values = frame.getAllValues(); + for (Map.Entry entry : values.entrySet()) { + // Skip internal keys + if (!entry.getKey().startsWith("_")) { + sb.append(" ").append(entry.getKey()).append(": ") + .append(entry.getValue()).append("\n"); + } + } + } + return sb.toString(); + } + + // ========== Context Frame Inner Class ========== + + /** + * Represents a single frame in the context stack. + * Contains the parser, command instance, and provides value access. + */ + public static class ContextFrame { + private final CommandLineParser parser; + private final Command command; + private final Map cachedValues; + private final Map inheritedValues; + private final Map inheritedOptions; + + public ContextFrame(CommandLineParser parser, Command command) { + this.parser = parser; + this.command = command; + this.cachedValues = new HashMap<>(); + this.inheritedValues = new HashMap<>(); + this.inheritedOptions = new HashMap<>(); + cacheFieldValues(); + } + + private void cacheFieldValues() { + // Cache all option and argument values for quick access + ProcessedCommand pc = parser.getProcessedCommand(); + + for (ProcessedOption opt : pc.getOptions()) { + Object value = getFieldValueByReflection(opt.getFieldName()); + if (value != null) { + cachedValues.put(opt.getFieldName(), value); + // Also cache by option name for convenience + if (opt.name() != null && !opt.name().isEmpty()) { + cachedValues.put(opt.name(), value); + } + // Track inherited options separately + if (opt.isInherited()) { + inheritedValues.put(opt.getFieldName(), value); + inheritedOptions.put(opt.getFieldName(), opt); + if (opt.name() != null && !opt.name().isEmpty()) { + inheritedValues.put(opt.name(), value); + } + } + } + } + + // Cache argument + if (pc.getArgument() != null) { + ProcessedOption arg = pc.getArgument(); + Object value = getFieldValueByReflection(arg.getFieldName()); + if (value != null) { + cachedValues.put(arg.getFieldName(), value); + cachedValues.put("_argument", value); + // Track inherited argument + if (arg.isInherited()) { + inheritedValues.put(arg.getFieldName(), value); + inheritedOptions.put(arg.getFieldName(), arg); + } + } + } + + // Cache arguments (list) + if (pc.getArguments() != null) { + Object value = getFieldValueByReflection(pc.getArguments().getFieldName()); + if (value != null) { + cachedValues.put(pc.getArguments().getFieldName(), value); + cachedValues.put("_arguments", value); + } + } + } + + private Object getFieldValueByReflection(String fieldName) { + try { + Field field = findField(command.getClass(), fieldName); + if (field != null) { + field.setAccessible(true); + return field.get(command); + } + } catch (IllegalAccessException e) { + // Ignore, return null + } + return null; + } + + private Field findField(Class clazz, String fieldName) { + while (clazz != null) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + return null; + } + + /** + * Get the command name. + * + * @return the command name + */ + public String getCommandName() { + return parser.getProcessedCommand().name(); + } + + /** + * Get the command instance with cached values restored. + * This is important because parsing subsequent commands may reset the command's fields. + * + * @return the command with restored values + */ + public Command getCommand() { + restoreCachedValues(); + return command; + } + + /** + * Restore cached values back to the command instance. + * This is needed because parsing may reset the command's fields. + */ + private void restoreCachedValues() { + for (Map.Entry entry : cachedValues.entrySet()) { + try { + Field field = findField(command.getClass(), entry.getKey()); + if (field != null) { + field.setAccessible(true); + field.set(command, entry.getValue()); + } + } catch (IllegalAccessException e) { + // Ignore, continue with other fields + } + } + } + + /** + * Get the parser. + * + * @return the command line parser + */ + public CommandLineParser getParser() { + return parser; + } + + /** + * Get a field value by name. + * + * @param fieldName the field name + * @return the value, or null if not found + */ + public Object getFieldValue(String fieldName) { + return cachedValues.get(fieldName); + } + + /** + * Get the primary value for display in prompt. + * Looks for arguments first, then common option names like "name", "projectName". + * + * @return the primary value as string, or null if none + */ + public String getPrimaryArgumentValue() { + // First check for @Argument + Object arg = cachedValues.get("_argument"); + if (arg != null) { + return arg.toString(); + } + // Check for @Arguments + Object args = cachedValues.get("_arguments"); + if (args instanceof List && !((List) args).isEmpty()) { + return ((List) args).get(0).toString(); + } + // Look for common option names that typically identify the context + String[] commonNames = {"name", "projectName", "moduleName", "id", "target"}; + for (String name : commonNames) { + Object value = cachedValues.get(name); + if (value != null) { + return value.toString(); + } + } + return null; + } + + /** + * Get all cached values. + * + * @return unmodifiable map of all values + */ + public Map getAllValues() { + return Collections.unmodifiableMap(cachedValues); + } + + /** + * Get an inherited value by field or option name. + * + * @param fieldName the field or option name + * @return the inherited value, or null if not found or not inherited + */ + public Object getInheritedValue(String fieldName) { + return inheritedValues.get(fieldName); + } + + /** + * Get all inherited values from this frame. + * + * @return unmodifiable map of inherited values + */ + public Map getInheritedValues() { + return Collections.unmodifiableMap(inheritedValues); + } + + /** + * Get all inherited options from this frame. + * + * @return unmodifiable map of inherited ProcessedOptions + */ + public Map getInheritedOptions() { + return Collections.unmodifiableMap(inheritedOptions); + } + + /** + * Format entry message showing all values. + * + * @return formatted string for display + */ + public String formatEntryMessage() { + StringBuilder sb = new StringBuilder(); + sb.append("Entering ").append(getCommandName()).append(" mode:"); + + boolean hasValues = false; + for (Map.Entry entry : cachedValues.entrySet()) { + // Skip internal keys + if (!entry.getKey().startsWith("_")) { + sb.append("\n ").append(entry.getKey()).append(": ").append(entry.getValue()); + hasValues = true; + } + } + + if (!hasValues) { + sb.append("\n (no options set)"); + } + + sb.append("\nType 'exit' to return."); + return sb.toString(); + } + } +} diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java index bfc6e5da..045f85ba 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java @@ -123,7 +123,7 @@ public void addOption(ProcessedOption opt) throws OptionParserException { opt.description(), opt.getArgument(), opt.isRequired(), opt.getValueSeparator(), opt.askIfNotSet(), opt.acceptNameWithoutDashes(), opt.selectorType(), opt.getDefaultValues(), opt.type(), opt.getFieldName(), opt.getOptionType(), opt.converter(), opt.completer(), opt.validator(), opt.activator(), opt.getRenderer(), opt.parser(), opt.doOverrideRequired(), - opt.isNegatable(), opt.getNegationPrefix())); + opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited())); options.get(options.size()-1).setParent(this); } @@ -134,7 +134,7 @@ private void setOptions(List options) throws OptionParserExcept opt.description(), opt.getArgument(), opt.isRequired(), opt.getValueSeparator(), opt.askIfNotSet(), opt.acceptNameWithoutDashes(), opt.selectorType(), opt.getDefaultValues(), opt.type(), opt.getFieldName(), opt.getOptionType(), opt.converter(), opt.completer(), opt.validator(), opt.activator(), opt.getRenderer(), - opt.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix())); + opt.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited())); this.options.get(this.options.size()-1).setParent(this); } diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java index 6c4e28a3..18fc5ba9 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java @@ -89,6 +89,7 @@ public final class ProcessedOption { private boolean negatable = false; private String negationPrefix = "no-"; private boolean negatedByUser = false; + private boolean inherited = false; public ProcessedOption(char shortName, String name, String description, String argument, boolean required, char valueSeparator, boolean askIfNotSet, boolean acceptNameWithoutDashes, @@ -98,7 +99,8 @@ public ProcessedOption(char shortName, String name, String description, OptionValidator optionValidator, OptionActivator activator, OptionRenderer renderer, OptionParser parser, - boolean overrideRequired, boolean negatable, String negationPrefix) throws OptionParserException { + boolean overrideRequired, boolean negatable, String negationPrefix, + boolean inherited) throws OptionParserException { if(shortName != '\u0000') this.shortName = String.valueOf(shortName); @@ -132,6 +134,7 @@ public ProcessedOption(char shortName, String name, String description, this.defaultValues = PropertiesLookup.checkForSystemVariables(defaultValue); this.negatable = negatable; this.negationPrefix = negationPrefix != null ? negationPrefix : "no-"; + this.inherited = inherited; properties = new HashMap<>(); values = new ArrayList<>(); @@ -321,6 +324,15 @@ public void setNegatedByUser(boolean negatedByUser) { this.negatedByUser = negatedByUser; } + /** + * Returns true if this option should be inherited by subcommands. + * Inherited options are automatically available to subcommands when + * in sub-command mode. + */ + public boolean isInherited() { + return inherited; + } + public void clear() { if(values != null) values.clear(); diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java index 0f81c00c..04a0fd9c 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java @@ -77,6 +77,7 @@ public class ProcessedOptionBuilder { private SelectorType selectorType; private boolean negatable = false; private String negationPrefix = "no-"; + private boolean inherited = false; private ProcessedOptionBuilder() { defaultValues = new ArrayList<>(); @@ -316,6 +317,14 @@ public ProcessedOptionBuilder negationPrefix(String negationPrefix) { return apply(c -> c.negationPrefix = negationPrefix); } + /** + * Set whether this option is inherited by subcommands. + * Only valid for options on group commands. + */ + public ProcessedOptionBuilder inherited(boolean inherited) { + return apply(c -> c.inherited = inherited); + } + public ProcessedOption build() throws OptionParserException { if(optionType == null) { if(!hasValue) @@ -369,6 +378,6 @@ else if(hasMultipleValues) return new ProcessedOption(shortName, name, description, argument, required, valueSeparator, askIfNotSet, acceptNameWithoutDashes, selectorType, defaultValues, type, fieldName, optionType, converter, - completer, validator, activator, renderer, parser, overrideRequired, negatable, negationPrefix); + completer, validator, activator, renderer, parser, overrideRequired, negatable, negationPrefix, inherited); } } diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java index 529da602..42615e47 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java @@ -23,8 +23,10 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; +import org.aesh.command.Command; import org.aesh.command.CommandRuntime; import org.aesh.command.container.CommandContainer; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.impl.shell.ShellOutputDelegate; import org.aesh.command.parser.CommandLineParserException; import org.aesh.command.invocation.CommandInvocation; @@ -36,6 +38,7 @@ import org.aesh.command.CommandNotFoundException; import org.aesh.command.invocation.CommandInvocationConfiguration; import org.aesh.console.Console; +import org.aesh.console.ReadlineConsole; import org.aesh.readline.Prompt; import org.aesh.readline.action.KeyAction; @@ -49,15 +52,25 @@ public final class AeshCommandInvocation implements CommandInvocation { private final CommandRuntime runtime; private final CommandInvocationConfiguration config; private final CommandContainer commandContainer; + private final CommandContext commandContext; public AeshCommandInvocation(Console console, Shell shell, CommandRuntime runtime, CommandInvocationConfiguration config, CommandContainer commandContainer) { + this(console, shell, runtime, config, commandContainer, null); + } + + public AeshCommandInvocation(Console console, Shell shell, + CommandRuntime runtime, + CommandInvocationConfiguration config, + CommandContainer commandContainer, + CommandContext commandContext) { this.console = console; this.runtime = runtime; this.config = config; this.commandContainer = commandContainer; + this.commandContext = commandContext; //if we have output redirection, use output delegate if (getConfiguration() != null && getConfiguration().getOutputRedirection() != null) { this.shell = new ShellOutputDelegate(shell, getConfiguration().getOutputRedirection()); @@ -150,4 +163,81 @@ public CommandInvocationConfiguration getConfiguration() { return config; } + @Override + public CommandContext getCommandContext() { + // If we have a direct reference, use it + if (commandContext != null) { + return commandContext; + } + // Otherwise try to get it from the console + if (console instanceof ReadlineConsole) { + return ((ReadlineConsole) console).getCommandContext(); + } + return null; + } + + @Override + public boolean enterSubCommandMode(Command command) { + if (!(console instanceof ReadlineConsole)) { + return false; + } + + ReadlineConsole readlineConsole = (ReadlineConsole) console; + CommandContext ctx = readlineConsole.getCommandContext(); + if (ctx == null) { + return false; + } + + // Check if sub-command mode is enabled + if (!ctx.getSettings().isEnabled()) { + return false; + } + + // Push the current command onto the context + ctx.push(commandContainer.getParser(), command); + + // Update the prompt to show the context + String newPrompt = ctx.buildPrompt(true); + console.setPrompt(new Prompt(newPrompt)); + + // Print entry message if configured + String commandName = commandContainer.getParser().getProcessedCommand().name(); + String enterMessage = ctx.formatEnterMessage(commandName); + if (enterMessage != null) { + println(enterMessage); + } + String exitHint = ctx.formatExitHint(); + if (exitHint != null) { + println(exitHint); + } + println(""); + + return true; + } + + @Override + public boolean exitSubCommandMode() { + if (!(console instanceof ReadlineConsole)) { + return false; + } + + ReadlineConsole readlineConsole = (ReadlineConsole) console; + CommandContext ctx = readlineConsole.getCommandContext(); + if (ctx == null || !ctx.isInSubCommandMode()) { + return false; + } + + // Pop the context + ctx.pop(); + + // Update the prompt + if (ctx.isInSubCommandMode()) { + console.setPrompt(new Prompt(ctx.buildPrompt(true))); + } else { + console.setPrompt(new Prompt(ctx.getOriginalPrompt())); + } + + return true; + } + } diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocationBuilder.java b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocationBuilder.java index 1e7c3ca9..24db46e3 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocationBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocationBuilder.java @@ -21,10 +21,12 @@ import org.aesh.command.CommandRuntime; import org.aesh.command.container.CommandContainer; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.shell.Shell; import org.aesh.command.invocation.CommandInvocationBuilder; import org.aesh.command.invocation.CommandInvocationConfiguration; import org.aesh.console.Console; +import org.aesh.console.ReadlineConsole; /** * @author Ståle W. Pedersen @@ -43,7 +45,23 @@ public AeshCommandInvocationBuilder(Shell shell, Console console) { public AeshCommandInvocation build(CommandRuntime runtime, CommandInvocationConfiguration config, CommandContainer commandContainer) { + // Get CommandContext from ReadlineConsole if available + CommandContext ctx = null; + if (console instanceof ReadlineConsole) { + ctx = ((ReadlineConsole) console).getCommandContext(); + } + if (ctx != null && ctx.isInSubCommandMode()) { + return new AeshCommandInvocation(console, shell, runtime, config, commandContainer, ctx); + } return new AeshCommandInvocation(console, shell, runtime, config, commandContainer); } + @Override + public AeshCommandInvocation build(CommandRuntime runtime, + CommandInvocationConfiguration config, + CommandContainer commandContainer, + CommandContext commandContext) { + return new AeshCommandInvocation(console, shell, runtime, config, commandContainer, commandContext); + } + } diff --git a/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java b/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java index eb916409..daf56518 100644 --- a/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java +++ b/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java @@ -19,11 +19,13 @@ */ package org.aesh.command.impl.populator; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.impl.internal.OptionType; import org.aesh.command.impl.internal.ProcessedCommand; import org.aesh.command.impl.internal.ProcessedOption; import org.aesh.command.impl.parser.CommandLineParser; import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.option.ParentCommand; import org.aesh.command.validator.OptionValidatorException; import org.aesh.command.populator.CommandPopulator; import org.aesh.console.AeshContext; @@ -34,6 +36,8 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; /** * @author Ståle W. Pedersen @@ -91,6 +95,168 @@ else if(processedCommand.getArgument() != null) resetField(getObject(), processedCommand.getArgument().getFieldName(), true); } + @Override + public void populateObject(ProcessedCommand, CI> processedCommand, + InvocationProviders invocationProviders, + AeshContext aeshContext, + CommandLineParser.Mode mode, + CommandContext commandContext) throws CommandLineParserException, OptionValidatorException { + // First, do the standard population + populateObject(processedCommand, invocationProviders, aeshContext, mode); + + // Then inject values if we have a command context + if (commandContext != null && commandContext.isInSubCommandMode()) { + // Inject @ParentCommand fields + injectParentCommands(commandContext); + // Inject inherited option values + injectInheritedValues(processedCommand, commandContext, invocationProviders, aeshContext); + } + } + + /** + * Inject parent command instances into fields annotated with @ParentCommand. + */ + private void injectParentCommands(CommandContext commandContext) { + List fields = getAllFields(getObject().getClass()); + for (Field field : fields) { + if (field.isAnnotationPresent(ParentCommand.class)) { + Class fieldType = field.getType(); + + // Find matching parent command in context stack + Command parent = commandContext.getParentCommand(fieldType.asSubclass(Command.class)); + + if (parent != null) { + try { + if (!Modifier.isPublic(field.getModifiers())) { + field.setAccessible(true); + } + field.set(getObject(), parent); + } catch (IllegalAccessException e) { + // Log warning but continue + e.printStackTrace(); + } + } + } + } + } + + /** + * Inject inherited option values from parent commands into subcommand fields. + * For each inherited option from parent commands, if the subcommand has a field + * with the same name and compatible type, and that field was not explicitly set + * by the user, inject the inherited value. + */ + private void injectInheritedValues(ProcessedCommand, CI> processedCommand, + CommandContext commandContext, + InvocationProviders invocationProviders, + AeshContext aeshContext) { + // Get all inherited options from the context + java.util.Map inheritedOptions = commandContext.getAllInheritedOptions(); + + if (inheritedOptions.isEmpty()) { + return; + } + + // For each option in the current command, check if there's a matching inherited option + for (ProcessedOption currentOpt : processedCommand.getOptions()) { + // Only inject if the option was not explicitly set by the user + if (currentOpt.getValues() == null || currentOpt.getValues().isEmpty()) { + // Check if there's an inherited option with the same field name or option name + ProcessedOption inheritedOpt = inheritedOptions.get(currentOpt.getFieldName()); + if (inheritedOpt == null && currentOpt.name() != null) { + inheritedOpt = inheritedOptions.get(currentOpt.name()); + } + + if (inheritedOpt != null) { + // Get the inherited value from context + Object inheritedValue = commandContext.getInheritedValue(currentOpt.getFieldName(), Object.class); + if (inheritedValue == null && currentOpt.name() != null) { + inheritedValue = commandContext.getInheritedValue(currentOpt.name(), Object.class); + } + + if (inheritedValue != null) { + // Inject the inherited value into the current command's field + try { + Field field = getField(getObject().getClass(), currentOpt.getFieldName()); + if (field != null) { + if (!Modifier.isPublic(field.getModifiers())) { + field.setAccessible(true); + } + // Check type compatibility (handle primitive types) + if (isTypeCompatible(field.getType(), inheritedValue.getClass())) { + field.set(getObject(), inheritedValue); + } + } + } catch (NoSuchFieldException | IllegalAccessException e) { + // Ignore, continue with other fields + } + } + } + } + } + + // Also check for argument inheritance + if (processedCommand.getArgument() != null) { + ProcessedOption currentArg = processedCommand.getArgument(); + if (currentArg.getValues() == null || currentArg.getValues().isEmpty()) { + ProcessedOption inheritedArg = inheritedOptions.get(currentArg.getFieldName()); + if (inheritedArg != null) { + Object inheritedValue = commandContext.getInheritedValue(currentArg.getFieldName(), Object.class); + if (inheritedValue != null) { + try { + Field field = getField(getObject().getClass(), currentArg.getFieldName()); + if (field != null) { + if (!Modifier.isPublic(field.getModifiers())) { + field.setAccessible(true); + } + if (isTypeCompatible(field.getType(), inheritedValue.getClass())) { + field.set(getObject(), inheritedValue); + } + } + } catch (NoSuchFieldException | IllegalAccessException e) { + // Ignore, continue + } + } + } + } + } + } + + /** + * Check if a value type is compatible with a field type, handling primitive types. + */ + private boolean isTypeCompatible(Class fieldType, Class valueType) { + if (fieldType.isAssignableFrom(valueType)) { + return true; + } + // Handle primitive type boxing/unboxing + if (fieldType.isPrimitive()) { + if (fieldType == boolean.class && valueType == Boolean.class) return true; + if (fieldType == int.class && valueType == Integer.class) return true; + if (fieldType == long.class && valueType == Long.class) return true; + if (fieldType == short.class && valueType == Short.class) return true; + if (fieldType == byte.class && valueType == Byte.class) return true; + if (fieldType == char.class && valueType == Character.class) return true; + if (fieldType == float.class && valueType == Float.class) return true; + if (fieldType == double.class && valueType == Double.class) return true; + } + return false; + } + + /** + * Get all fields from a class and its superclasses. + */ + private List getAllFields(Class clazz) { + List fields = new ArrayList<>(); + while (clazz != null) { + for (Field field : clazz.getDeclaredFields()) { + fields.add(field); + } + clazz = clazz.getSuperclass(); + } + return fields; + } + /** * Will parse the input line and populate the fields in the instance object specified by * the given annotations. diff --git a/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java b/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java index 2477f4f3..7a3ccb71 100644 --- a/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java @@ -23,7 +23,9 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; +import org.aesh.command.Command; import org.aesh.command.Executor; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.parser.CommandLineParserException; import org.aesh.command.validator.CommandValidatorException; import org.aesh.command.validator.OptionValidatorException; @@ -164,4 +166,132 @@ default void println(String msg) { */ void println(String msg, boolean paging); + // ========== Parent Command Context Methods ========== + + /** + * Get the current command context for sub-command mode. + * The context provides access to parent command values and state. + * + * @return the command context, or null if not available + */ + default CommandContext getCommandContext() { + return null; + } + + /** + * Get a value from a parent command by field or option name. + * Searches from immediate parent up to root. + * + * @param name The field or option name + * @param type The expected type + * @param the value type + * @return The value, or null if not found + */ + default T getParentValue(String name, Class type) { + CommandContext ctx = getCommandContext(); + return ctx != null ? ctx.getParentValue(name, type) : null; + } + + /** + * Get a value from a parent command with a default value. + * + * @param name The field or option name + * @param type The expected type + * @param defaultValue The default value if not found + * @param the value type + * @return The value, or defaultValue if not found + */ + default T getParentValue(String name, Class type, T defaultValue) { + CommandContext ctx = getCommandContext(); + return ctx != null ? ctx.getParentValue(name, type, defaultValue) : defaultValue; + } + + /** + * Get the immediate parent command instance. + * + * @return the parent command, or null if not in sub-command mode + */ + default Command getParentCommand() { + CommandContext ctx = getCommandContext(); + return ctx != null ? ctx.getParentCommand() : null; + } + + /** + * Get a specific parent command by type. + * + * @param type the command class to find + * @param the command type + * @return the matching parent command, or null if not found + */ + default > T getParentCommand(Class type) { + CommandContext ctx = getCommandContext(); + return ctx != null ? ctx.getParentCommand(type) : null; + } + + /** + * Check if currently executing in sub-command mode. + * + * @return true if in sub-command mode + */ + default boolean isInSubCommandMode() { + CommandContext ctx = getCommandContext(); + return ctx != null && ctx.isInSubCommandMode(); + } + + // ========== Inherited Value Access Methods ========== + + /** + * Get an inherited value from parent commands. + * Only returns values from options marked with inherited=true. + * + * @param name The field or option name + * @param type The expected type + * @param the value type + * @return The inherited value, or null if not found + */ + default T getInheritedValue(String name, Class type) { + CommandContext ctx = getCommandContext(); + return ctx != null ? ctx.getInheritedValue(name, type) : null; + } + + /** + * Get an inherited value from parent commands with a default. + * Only returns values from options marked with inherited=true. + * + * @param name The field or option name + * @param type The expected type + * @param defaultValue The default value if not found + * @param the value type + * @return The inherited value, or defaultValue if not found + */ + default T getInheritedValue(String name, Class type, T defaultValue) { + CommandContext ctx = getCommandContext(); + return ctx != null ? ctx.getInheritedValue(name, type, defaultValue) : defaultValue; + } + + /** + * Enter sub-command mode for the current group command. + * This pushes the current command onto the context stack and changes the prompt. + * Subsequent commands will have access to this command's values via + * {@link #getParentValue} and {@link #getParentCommand}. + * + * Type 'exit' to leave sub-command mode. + * + * @param command The group command instance to push onto the context + * @return true if sub-command mode was entered successfully + */ + default boolean enterSubCommandMode(Command command) { + return false; // Default implementation does nothing + } + + /** + * Exit the current sub-command mode level. + * This pops the current context and restores the previous prompt. + * + * @return true if a context level was exited, false if not in sub-command mode + */ + default boolean exitSubCommandMode() { + return false; // Default implementation does nothing + } + } diff --git a/aesh/src/main/java/org/aesh/command/invocation/CommandInvocationBuilder.java b/aesh/src/main/java/org/aesh/command/invocation/CommandInvocationBuilder.java index f787090e..9b4bb1cf 100644 --- a/aesh/src/main/java/org/aesh/command/invocation/CommandInvocationBuilder.java +++ b/aesh/src/main/java/org/aesh/command/invocation/CommandInvocationBuilder.java @@ -21,6 +21,7 @@ import org.aesh.command.CommandRuntime; import org.aesh.command.container.CommandContainer; +import org.aesh.command.impl.context.CommandContext; /** * @author Ståle W. Pedersen @@ -29,4 +30,21 @@ public interface CommandInvocationBuilder { CI build(CommandRuntime runtime, CommandInvocationConfiguration configuration, CommandContainer commandContainer); + + /** + * Build a command invocation with a command context for sub-command mode. + * + * @param runtime the command runtime + * @param configuration the invocation configuration + * @param commandContainer the command container + * @param commandContext the command context (may be null) + * @return the command invocation + */ + default CI build(CommandRuntime runtime, + CommandInvocationConfiguration configuration, + CommandContainer commandContainer, + CommandContext commandContext) { + // Default implementation ignores context for backward compatibility + return build(runtime, configuration, commandContainer); + } } diff --git a/aesh/src/main/java/org/aesh/command/option/Argument.java b/aesh/src/main/java/org/aesh/command/option/Argument.java index 31abcbf5..d8a2b898 100644 --- a/aesh/src/main/java/org/aesh/command/option/Argument.java +++ b/aesh/src/main/java/org/aesh/command/option/Argument.java @@ -117,4 +117,12 @@ */ Class parser() default AeshOptionParser.class; + /** + * When true, this argument value is available to all subcommands. + * Subcommands can access the value via CommandInvocation.getInheritedValue() + * or it will be auto-populated into a field with the same name. + * Only valid on group commands. + */ + boolean inherited() default false; + } diff --git a/aesh/src/main/java/org/aesh/command/option/Option.java b/aesh/src/main/java/org/aesh/command/option/Option.java index a00b0cfc..e93f5050 100644 --- a/aesh/src/main/java/org/aesh/command/option/Option.java +++ b/aesh/src/main/java/org/aesh/command/option/Option.java @@ -166,4 +166,12 @@ */ String negationPrefix() default "no-"; + /** + * When true, this option is automatically available to all subcommands. + * Subcommands can access the value via CommandInvocation.getInheritedValue() + * or it will be auto-populated into a field with the same name. + * Only valid on group commands. + */ + boolean inherited() default false; + } diff --git a/aesh/src/main/java/org/aesh/command/option/ParentCommand.java b/aesh/src/main/java/org/aesh/command/option/ParentCommand.java new file mode 100644 index 00000000..b97dff44 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/option/ParentCommand.java @@ -0,0 +1,73 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.option; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Marks a field to receive the parent command instance when executing + * as a subcommand. The field type must match the parent command class. + * + *

This annotation enables subcommands to access the parsed options + * and arguments of their parent group command. It works both in + * sub-command mode (interactive) and when commands are invoked directly + * (e.g., "parent --option value sub --suboption").

+ * + *

Example usage:

+ *
+ * {@literal @}GroupCommandDefinition(name = "module", groupCommands = {TagCommand.class})
+ * public class ModuleCommand implements Command<CommandInvocation> {
+ *     {@literal @}Option(name = "verbose", hasValue = false)
+ *     private boolean verbose;
+ *
+ *     {@literal @}Argument
+ *     private String moduleName;
+ *     // ...
+ * }
+ *
+ * {@literal @}CommandDefinition(name = "tag", description = "Manage tags")
+ * public class TagCommand implements Command<CommandInvocation> {
+ *
+ *     {@literal @}ParentCommand
+ *     private ModuleCommand parent;
+ *
+ *     {@literal @}Argument
+ *     private String tagName;
+ *
+ *     public CommandResult execute(CommandInvocation invocation) {
+ *         // Access parent's parsed values
+ *         String module = parent.getModuleName();
+ *         boolean verbose = parent.isVerbose();
+ *         // ...
+ *     }
+ * }
+ * 
+ * + * @author Ståle W. Pedersen + */ +@Retention(RUNTIME) +@Target(FIELD) +public @interface ParentCommand { + // No properties needed - the field type determines which parent to inject +} diff --git a/aesh/src/main/java/org/aesh/command/populator/CommandPopulator.java b/aesh/src/main/java/org/aesh/command/populator/CommandPopulator.java index 3acbe213..51392738 100644 --- a/aesh/src/main/java/org/aesh/command/populator/CommandPopulator.java +++ b/aesh/src/main/java/org/aesh/command/populator/CommandPopulator.java @@ -20,6 +20,7 @@ package org.aesh.command.populator; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.impl.internal.ProcessedCommand; import org.aesh.command.impl.parser.CommandLineParser; import org.aesh.command.invocation.CommandInvocation; @@ -45,6 +46,27 @@ public interface CommandPopulator { void populateObject(ProcessedCommand,CI> processedCommand, InvocationProviders invocationProviders, AeshContext aeshContext, CommandLineParser.Mode mode) throws CommandLineParserException, OptionValidatorException; + /** + * Populate a Command instance with the values parsed from a command line, + * including parent command injection via @ParentCommand annotation. + * + * @param processedCommand command line + * @param invocationProviders providers + * @param aeshContext the context + * @param mode based on rules given to the parser + * @param commandContext the command context for parent command access (may be null) + * @throws CommandLineParserException + * @throws OptionValidatorException + */ + default void populateObject(ProcessedCommand,CI> processedCommand, + InvocationProviders invocationProviders, + AeshContext aeshContext, + CommandLineParser.Mode mode, + CommandContext commandContext) throws CommandLineParserException, OptionValidatorException { + // Default implementation ignores context for backward compatibility + populateObject(processedCommand, invocationProviders, aeshContext, mode); + } + /** * @return the object instance that will be populated. */ diff --git a/aesh/src/main/java/org/aesh/command/settings/DefaultSubCommandModeSettings.java b/aesh/src/main/java/org/aesh/command/settings/DefaultSubCommandModeSettings.java new file mode 100644 index 00000000..4e865567 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/settings/DefaultSubCommandModeSettings.java @@ -0,0 +1,135 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.settings; + +/** + * Default implementation of SubCommandModeSettings with sensible defaults. + * + * @author Ståle W. Pedersen + */ +class DefaultSubCommandModeSettings implements SubCommandModeSettings { + + static final DefaultSubCommandModeSettings INSTANCE = new DefaultSubCommandModeSettings(); + + private final boolean enabled; + private final String exitCommand; + private final String alternativeExitCommand; + private final String contextSeparator; + private final boolean showContextOnEntry; + private final boolean showArgumentInPrompt; + private final String contextCommand; + private final String enterMessage; + private final String exitMessage; + private final String exitHint; + private final boolean exitOnCtrlC; + + /** + * Create default settings. + */ + DefaultSubCommandModeSettings() { + this.enabled = true; + this.exitCommand = "exit"; + this.alternativeExitCommand = ".."; + this.contextSeparator = ":"; + this.showContextOnEntry = true; + this.showArgumentInPrompt = true; + this.contextCommand = "context"; + this.enterMessage = "Entering {name} mode."; + this.exitMessage = null; + this.exitHint = "Type '{exit}' to return."; + this.exitOnCtrlC = true; + } + + /** + * Create settings with specified values. + */ + DefaultSubCommandModeSettings(boolean enabled, String exitCommand, String alternativeExitCommand, + String contextSeparator, boolean showContextOnEntry, + boolean showArgumentInPrompt, String contextCommand, + String enterMessage, String exitMessage, String exitHint, + boolean exitOnCtrlC) { + this.enabled = enabled; + this.exitCommand = exitCommand; + this.alternativeExitCommand = alternativeExitCommand; + this.contextSeparator = contextSeparator; + this.showContextOnEntry = showContextOnEntry; + this.showArgumentInPrompt = showArgumentInPrompt; + this.contextCommand = contextCommand; + this.enterMessage = enterMessage; + this.exitMessage = exitMessage; + this.exitHint = exitHint; + this.exitOnCtrlC = exitOnCtrlC; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public String getExitCommand() { + return exitCommand; + } + + @Override + public String getAlternativeExitCommand() { + return alternativeExitCommand; + } + + @Override + public String getContextSeparator() { + return contextSeparator; + } + + @Override + public boolean showContextOnEntry() { + return showContextOnEntry; + } + + @Override + public boolean showArgumentInPrompt() { + return showArgumentInPrompt; + } + + @Override + public String getContextCommand() { + return contextCommand; + } + + @Override + public String getEnterMessage() { + return enterMessage; + } + + @Override + public String getExitMessage() { + return exitMessage; + } + + @Override + public String getExitHint() { + return exitHint; + } + + @Override + public boolean exitOnCtrlC() { + return exitOnCtrlC; + } +} diff --git a/aesh/src/main/java/org/aesh/command/settings/Settings.java b/aesh/src/main/java/org/aesh/command/settings/Settings.java index a5efc3a6..9fbd1d88 100644 --- a/aesh/src/main/java/org/aesh/command/settings/Settings.java +++ b/aesh/src/main/java/org/aesh/command/settings/Settings.java @@ -308,4 +308,11 @@ public interface Settings enableSearchInPaging(boolean enable) return apply(c -> c.settings.setEnableSearchInPaging(enable)); } + public SettingsBuilder subCommandModeSettings(SubCommandModeSettings subCommandModeSettings) { + return apply(c -> c.settings.setSubCommandModeSettings(subCommandModeSettings)); + } + public Settings build() { if(settings.logging()) LoggerUtil.doLog(); diff --git a/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java b/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java index 5704d191..9559a731 100644 --- a/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java +++ b/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java @@ -110,6 +110,7 @@ public class SettingsImpl connectionClosedHandler; + private SubCommandModeSettings subCommandModeSettings; SettingsImpl() { } @@ -162,6 +163,7 @@ protected SettingsImpl(Settings baseSettings) { setEnableSearchInPaging(baseSettings.enableSearchInPaging()); setAliasManager(baseSettings.aliasManager()); setConnectionClosedHandler(baseSettings.connectionClosedHandler()); + setSubCommandModeSettings(baseSettings.subCommandModeSettings()); } public void resetToDefaults() { @@ -825,4 +827,16 @@ public void setEnableSearchInPaging(boolean enable) { public boolean enableSearchInPaging() { return enableSearchPaging; } + + @Override + public SubCommandModeSettings subCommandModeSettings() { + if (subCommandModeSettings == null) { + subCommandModeSettings = SubCommandModeSettings.defaults(); + } + return subCommandModeSettings; + } + + public void setSubCommandModeSettings(SubCommandModeSettings subCommandModeSettings) { + this.subCommandModeSettings = subCommandModeSettings; + } } diff --git a/aesh/src/main/java/org/aesh/command/settings/SubCommandModeSettings.java b/aesh/src/main/java/org/aesh/command/settings/SubCommandModeSettings.java new file mode 100644 index 00000000..0c84df96 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/settings/SubCommandModeSettings.java @@ -0,0 +1,133 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.settings; + +/** + * Configuration options for sub-command mode behavior. + * Sub-command mode allows users to enter an interactive context for group commands + * where subsequent commands are executed within that context. + * + * @author Ståle W. Pedersen + */ +public interface SubCommandModeSettings { + + /** + * Check if sub-command mode is enabled globally. + * When disabled, group commands will not enter interactive sub-command mode. + * + * @return true if sub-command mode is enabled (default: true) + */ + boolean isEnabled(); + + /** + * Get the primary command to exit sub-command mode. + * + * @return the exit command (default: "exit") + */ + String getExitCommand(); + + /** + * Get the alternative command to exit sub-command mode. + * Set to null to disable the alternative exit command. + * + * @return the alternative exit command (default: "..") + */ + String getAlternativeExitCommand(); + + /** + * Get the separator used for nested context paths in the prompt. + * For example, with separator ":", nested contexts appear as "module:project>" + * + * @return the context separator (default: ":") + */ + String getContextSeparator(); + + /** + * Check if option/argument values should be displayed when entering sub-command mode. + * + * @return true to show context values on entry (default: true) + */ + boolean showContextOnEntry(); + + /** + * Check if the primary argument/option value should be shown in the prompt. + * For example, "project[myapp]>" vs "project>" + * + * @return true to show argument in prompt (default: true) + */ + boolean showArgumentInPrompt(); + + /** + * Get the command name to display/redisplay current context values. + * Set to null to disable the context command. + * + * @return the context command name (default: "context") + */ + String getContextCommand(); + + /** + * Get the message format shown when entering sub-command mode. + * Supports placeholders: {name} for command name. + * + * @return the enter message format (default: "Entering {name} mode.") + */ + String getEnterMessage(); + + /** + * Get the message format shown when exiting sub-command mode. + * Supports placeholders: {name} for command name. + * + * @return the exit message format (default: null - no message) + */ + String getExitMessage(); + + /** + * Get the format for the exit hint message. + * Supports placeholders: {exit} for exit command, {alt} for alternative exit. + * + * @return the exit hint format (default: "Type '{exit}' to return.") + */ + String getExitHint(); + + /** + * Check if Ctrl+C should exit sub-command mode instead of interrupting. + * + * @return true if Ctrl+C exits sub-command mode (default: true) + */ + boolean exitOnCtrlC(); + + /** + * Get the default settings instance with all default values. + * + * @return default SubCommandModeSettings + */ + static SubCommandModeSettings defaults() { + return DefaultSubCommandModeSettings.INSTANCE; + } + + /** + * Create a new builder for SubCommandModeSettings. + * + * @return a new builder + */ + static SubCommandModeSettingsBuilder builder() { + return new SubCommandModeSettingsBuilder(); + } +} diff --git a/aesh/src/main/java/org/aesh/command/settings/SubCommandModeSettingsBuilder.java b/aesh/src/main/java/org/aesh/command/settings/SubCommandModeSettingsBuilder.java new file mode 100644 index 00000000..b2bdbbe4 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/settings/SubCommandModeSettingsBuilder.java @@ -0,0 +1,181 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.settings; + +/** + * Builder for SubCommandModeSettings. + * + * @author Ståle W. Pedersen + */ +public class SubCommandModeSettingsBuilder { + + private boolean enabled = true; + private String exitCommand = "exit"; + private String alternativeExitCommand = ".."; + private String contextSeparator = ":"; + private boolean showContextOnEntry = true; + private boolean showArgumentInPrompt = true; + private String contextCommand = "context"; + private String enterMessage = "Entering {name} mode."; + private String exitMessage = null; + private String exitHint = "Type '{exit}' to return."; + private boolean exitOnCtrlC = true; + + SubCommandModeSettingsBuilder() { + } + + /** + * Enable or disable sub-command mode globally. + * + * @param enabled true to enable (default: true) + * @return this builder + */ + public SubCommandModeSettingsBuilder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Set the primary exit command. + * + * @param exitCommand the exit command (default: "exit") + * @return this builder + */ + public SubCommandModeSettingsBuilder exitCommand(String exitCommand) { + this.exitCommand = exitCommand; + return this; + } + + /** + * Set the alternative exit command. + * Set to null to disable. + * + * @param alternativeExitCommand the alternative exit command (default: "..") + * @return this builder + */ + public SubCommandModeSettingsBuilder alternativeExitCommand(String alternativeExitCommand) { + this.alternativeExitCommand = alternativeExitCommand; + return this; + } + + /** + * Set the context path separator. + * + * @param contextSeparator the separator (default: ":") + * @return this builder + */ + public SubCommandModeSettingsBuilder contextSeparator(String contextSeparator) { + this.contextSeparator = contextSeparator; + return this; + } + + /** + * Set whether to show context values when entering sub-command mode. + * + * @param showContextOnEntry true to show (default: true) + * @return this builder + */ + public SubCommandModeSettingsBuilder showContextOnEntry(boolean showContextOnEntry) { + this.showContextOnEntry = showContextOnEntry; + return this; + } + + /** + * Set whether to show argument value in the prompt. + * + * @param showArgumentInPrompt true to show (default: true) + * @return this builder + */ + public SubCommandModeSettingsBuilder showArgumentInPrompt(boolean showArgumentInPrompt) { + this.showArgumentInPrompt = showArgumentInPrompt; + return this; + } + + /** + * Set the context command name. + * Set to null to disable. + * + * @param contextCommand the command name (default: "context") + * @return this builder + */ + public SubCommandModeSettingsBuilder contextCommand(String contextCommand) { + this.contextCommand = contextCommand; + return this; + } + + /** + * Set the message shown when entering sub-command mode. + * Supports placeholder: {name} + * + * @param enterMessage the message (default: "Entering {name} mode.") + * @return this builder + */ + public SubCommandModeSettingsBuilder enterMessage(String enterMessage) { + this.enterMessage = enterMessage; + return this; + } + + /** + * Set the message shown when exiting sub-command mode. + * Supports placeholder: {name} + * + * @param exitMessage the message (default: null - no message) + * @return this builder + */ + public SubCommandModeSettingsBuilder exitMessage(String exitMessage) { + this.exitMessage = exitMessage; + return this; + } + + /** + * Set the exit hint message format. + * Supports placeholders: {exit}, {alt} + * + * @param exitHint the hint format (default: "Type '{exit}' to return.") + * @return this builder + */ + public SubCommandModeSettingsBuilder exitHint(String exitHint) { + this.exitHint = exitHint; + return this; + } + + /** + * Set whether Ctrl+C should exit sub-command mode. + * + * @param exitOnCtrlC true to exit on Ctrl+C (default: true) + * @return this builder + */ + public SubCommandModeSettingsBuilder exitOnCtrlC(boolean exitOnCtrlC) { + this.exitOnCtrlC = exitOnCtrlC; + return this; + } + + /** + * Build the SubCommandModeSettings instance. + * + * @return the configured settings + */ + public SubCommandModeSettings build() { + return new DefaultSubCommandModeSettings( + enabled, exitCommand, alternativeExitCommand, contextSeparator, + showContextOnEntry, showArgumentInPrompt, contextCommand, + enterMessage, exitMessage, exitHint, exitOnCtrlC); + } +} diff --git a/aesh/src/main/java/org/aesh/console/ReadlineConsole.java b/aesh/src/main/java/org/aesh/console/ReadlineConsole.java index d66a6ea3..2ec8dda6 100644 --- a/aesh/src/main/java/org/aesh/console/ReadlineConsole.java +++ b/aesh/src/main/java/org/aesh/console/ReadlineConsole.java @@ -38,6 +38,7 @@ import org.aesh.command.export.ExportPreProcessor; import org.aesh.command.impl.AeshCommandResolver; import org.aesh.command.impl.completer.AeshCompletionHandler; +import org.aesh.command.impl.context.CommandContext; import org.aesh.command.impl.invocation.AeshCommandInvocationBuilder; import org.aesh.command.impl.registry.MutableCommandRegistryImpl; import org.aesh.command.invocation.CommandInvocation; @@ -112,6 +113,7 @@ public class ReadlineConsole implements Console, Consumer { private History history; private ShellImpl shell; + private CommandContext commandContext; private final EnumMap readlineFlags = new EnumMap<>(ReadlineFlag.class); @@ -234,11 +236,29 @@ public void accept(Connection connection) { attr.setLocalFlag(Attributes.LocalFlag.ECHOCTL, false); connection.setAttributes(attr); } - if(settings.getInterruptHandler() != null) { - connection.setSignalHandler((Signal t) -> { + // Set up signal handler for Ctrl+C + connection.setSignalHandler((Signal t) -> { + if (t == Signal.INT) { + // If in sub-command mode and exitOnCtrlC is enabled, exit sub-command mode + if (commandContext != null && commandContext.isInSubCommandMode() + && commandContext.getSettings().exitOnCtrlC()) { + commandContext.pop(); + // Update prompt - the readline will finish with empty string + // and the normal flow will restart with the new prompt + if (commandContext.isInSubCommandMode()) { + setPrompt(new Prompt(commandContext.buildPrompt(true))); + } else { + setPrompt(new Prompt(commandContext.getOriginalPrompt())); + } + // Don't call the user's interrupt handler since we handled it + return; + } + } + // Call user's interrupt handler if set + if (settings.getInterruptHandler() != null) { settings.getInterruptHandler().accept(null); - }); - } + } + }); this.runtime = generateRuntime(); read(this.connection, readline); @@ -248,8 +268,18 @@ public void accept(Connection connection) { private void init() { completionHandler = new AeshCompletionHandler(context); + String originalPromptString = ""; if(prompt == null) prompt = new Prompt(""); + else { + // Convert prompt's int[] back to String for CommandContext + int[] promptCodes = prompt.getPromptAsString(); + if (promptCodes != null && promptCodes.length > 0) { + originalPromptString = new String(promptCodes, 0, promptCodes.length); + } + } + // Initialize command context for sub-command mode + commandContext = new CommandContext(originalPromptString, settings.subCommandModeSettings()); if (settings.historyPersistent()) { history = new FileHistory(settings.historyFile(), settings.historySize(), buildPermission(settings.historyFilePermission()), settings.logging()); @@ -297,7 +327,77 @@ public void read(final Connection conn, final Readline readline) { } } + /** + * Display current context information when the context command is invoked. + */ + private void displayContextInfo(Connection conn) { + if (commandContext == null || !commandContext.isInSubCommandMode()) { + conn.write("Not in sub-command mode.\n"); + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append("=== Current Context ===\n"); + sb.append("Path: ").append(commandContext.getContextPath()).append("\n"); + sb.append("Depth: ").append(commandContext.depth()).append("\n"); + sb.append("\n"); + + // Display values from each context level + sb.append(commandContext.formatContextValues()); + + // Display inherited values if any + java.util.Map inherited = commandContext.getAllInheritedValues(); + if (!inherited.isEmpty()) { + sb.append("\nInherited values:\n"); + for (java.util.Map.Entry entry : inherited.entrySet()) { + // Skip internal keys and duplicates (option name vs field name) + if (!entry.getKey().startsWith("_")) { + sb.append(" ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); + } + } + } + + // Display exit hints + sb.append("\n"); + String exitHint = commandContext.formatExitHint(); + if (exitHint != null) { + sb.append(exitHint).append("\n"); + } + + conn.write(sb.toString()); + } + private void processLine(String line, Connection conn) { + // Handle special commands in sub-command mode + if (commandContext != null && commandContext.isInSubCommandMode()) { + // Handle exit command + if (commandContext.isExitCommand(line)) { + commandContext.pop(); + // Update prompt + if (commandContext.isInSubCommandMode()) { + setPrompt(new Prompt(commandContext.buildPrompt(true))); + } else { + setPrompt(new Prompt(commandContext.getOriginalPrompt())); + } + read(conn, readline); + return; + } + + // Handle context command - display current context values + String contextCommand = commandContext.getSettings().getContextCommand(); + if (contextCommand != null && line.trim().equals(contextCommand)) { + displayContextInfo(conn); + read(conn, readline); + return; + } + + // Prefix command with context path + String contextPath = commandContext.getContextPathWithSpaces(); + if (!contextPath.isEmpty()) { + line = contextPath + " " + line; + } + } + try { Executor executor = runtime.buildExecutor(line); processManager.execute(executor, conn); @@ -409,16 +509,40 @@ private AeshCommandResolver getCommandResolverThrou class AeshCompletion implements Completion { @Override public void complete(AeshCompleteOperation completeOperation) { + // In sub-command mode, prefix the buffer with context path + if (commandContext != null && commandContext.isInSubCommandMode()) { + String contextPath = commandContext.getContextPathWithSpaces(); + if (!contextPath.isEmpty()) { + String originalBuffer = completeOperation.getBuffer(); + int originalCursor = completeOperation.getCursor(); + + // Create a new complete operation with context prefix + String prefixedBuffer = contextPath + " " + originalBuffer; + int prefixLength = contextPath.length() + 1; + + AeshCompleteOperation prefixedOperation = new AeshCompleteOperation( + context, prefixedBuffer, originalCursor + prefixLength); + + // Run completion on the prefixed buffer + runtime.complete(prefixedOperation); + + // Transfer results back to original operation + completeOperation.addCompletionCandidatesTerminalString( + prefixedOperation.getCompletionCandidates()); + completeOperation.setIgnoreOffset(prefixedOperation.doIgnoreOffset()); + completeOperation.setIgnoreStartsWith(prefixedOperation.isIgnoreStartsWith()); + + // Adjust offset to account for the prefix + int newOffset = prefixedOperation.getOffset() - prefixLength; + if (newOffset >= 0) { + completeOperation.setOffset(newOffset); + } + + return; + } + } runtime.complete(completeOperation); } - - /* TODO - if(internalRegistry != null) { - for (String internalCommand : internalRegistry.getAllCommandNames()) - if (internalCommand.startsWith(co.getBuffer())) - co.addCompletionCandidate(internalCommand); - } - */ } private CommandRuntime generateRuntime() { @@ -431,4 +555,22 @@ private CommandRuntime generateRuntime() { .build(); } + /** + * Get the command context for sub-command mode. + * + * @return the command context + */ + public CommandContext getCommandContext() { + return commandContext; + } + + /** + * Check if currently in sub-command mode. + * + * @return true if in sub-command mode + */ + public boolean isInSubCommandMode() { + return commandContext != null && commandContext.isInSubCommandMode(); + } + } diff --git a/aesh/src/test/java/org/aesh/command/parser/ParentCommandTest.java b/aesh/src/test/java/org/aesh/command/parser/ParentCommandTest.java new file mode 100644 index 00000000..c422af5f --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/parser/ParentCommandTest.java @@ -0,0 +1,373 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.parser; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandException; +import org.aesh.command.CommandResult; +import org.aesh.command.GroupCommandDefinition; +import org.aesh.command.impl.activator.AeshCommandActivatorProvider; +import org.aesh.command.impl.activator.AeshOptionActivatorProvider; +import org.aesh.command.impl.completer.AeshCompleterInvocationProvider; +import org.aesh.command.impl.container.AeshCommandContainerBuilder; +import org.aesh.command.impl.context.CommandContext; +import org.aesh.command.impl.converter.AeshConverterInvocationProvider; +import org.aesh.command.impl.invocation.AeshInvocationProviders; +import org.aesh.command.impl.parser.CommandLineParser; +import org.aesh.command.impl.validator.AeshValidatorInvocationProvider; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.invocation.InvocationProviders; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; +import org.aesh.command.option.ParentCommand; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.console.AeshContext; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Tests for @ParentCommand annotation and CommandContext. + * + * @author Ståle W. Pedersen + */ +public class ParentCommandTest { + + private final InvocationProviders invocationProviders = new AeshInvocationProviders( + SettingsBuilder.builder() + .converterInvocationProvider(new AeshConverterInvocationProvider()) + .completerInvocationProvider(new AeshCompleterInvocationProvider<>()) + .validatorInvocationProvider(new AeshValidatorInvocationProvider()) + .optionActivatorProvider(new AeshOptionActivatorProvider()) + .commandActivatorProvider(new AeshCommandActivatorProvider()).build()); + + @Test + public void testCommandContext() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + CommandLineParser parser = new AeshCommandContainerBuilder<>() + .create(new ModuleGroupCommand<>()).getParser(); + + // Parse the group command with options + parser.parse("module --verbose --name=my-module-name", CommandLineParser.Mode.STRICT); + parser.getCommandPopulator().populateObject( + parser.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + + ModuleGroupCommand moduleCmd = + (ModuleGroupCommand) parser.getCommand(); + + // Create a CommandContext and push the parent + CommandContext ctx = new CommandContext("aesh> "); + ctx.push(parser, moduleCmd); + + // Verify context state + assertTrue(ctx.isInSubCommandMode()); + assertEquals("module", ctx.getContextPath()); + assertEquals("my-module-name", ctx.getParentValue("moduleName", String.class)); + assertTrue(ctx.getParentValue("verbose", Boolean.class)); + + // Verify prompt building (shows option value "name" in prompt) + assertEquals("module[my-module-name]> ", ctx.buildPrompt(true)); + assertEquals("module> ", ctx.buildPrompt(false)); + } + + @Test + public void testParentCommandInjection() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Step 1: Parse and populate the parent command (simulating entering sub-command mode) + CommandLineParser parentParser = new AeshCommandContainerBuilder<>() + .create(new ModuleGroupCommand<>()).getParser(); + parentParser.parse("module --verbose --name=my-module-name", CommandLineParser.Mode.STRICT); + parentParser.getCommandPopulator().populateObject( + parentParser.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + + ModuleGroupCommand moduleCmd = + (ModuleGroupCommand) parentParser.getCommand(); + + // Verify parent values + assertEquals("my-module-name", moduleCmd.moduleName); + assertTrue(moduleCmd.verbose); + + // Step 2: Create context with parent + CommandContext ctx = new CommandContext("aesh> "); + ctx.push(parentParser, moduleCmd); + + // Step 3: Get the child parser and parse subcommand (simulating typing in sub-command mode) + CommandLineParser childParser = parentParser.getChildParser("tag"); + assertNotNull(childParser); + childParser.parse("tag v1.0", CommandLineParser.Mode.STRICT); + + // Step 4: Populate the subcommand with context to inject parent + childParser.getCommandPopulator().populateObject( + childParser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE, ctx); + + TagCommand tagCmd = + (TagCommand) childParser.getCommand(); + + // Verify subcommand's own argument was populated + assertEquals("v1.0", tagCmd.tagName); + + // Verify parent was injected + assertNotNull(tagCmd.parent); + assertEquals("my-module-name", tagCmd.parent.moduleName); + assertTrue(tagCmd.parent.verbose); + } + + @Test + public void testNestedContext() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Create a nested context + CommandContext ctx = new CommandContext("aesh> "); + + // Simulate entering first level + CommandLineParser parser1 = new AeshCommandContainerBuilder<>() + .create(new ModuleGroupCommand<>()).getParser(); + parser1.parse("module --verbose --name=my-module", CommandLineParser.Mode.STRICT); + parser1.getCommandPopulator().populateObject( + parser1.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + ctx.push(parser1, parser1.getCommand()); + + assertEquals(1, ctx.depth()); + assertEquals("module", ctx.getContextPath()); + + // Simulate entering second level (nested group) + CommandLineParser parser2 = new AeshCommandContainerBuilder<>() + .create(new ProjectGroupCommand<>()).getParser(); + parser2.parse("project --name my-project", CommandLineParser.Mode.STRICT); + parser2.getCommandPopulator().populateObject( + parser2.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + ctx.push(parser2, parser2.getCommand()); + + assertEquals(2, ctx.depth()); + assertEquals("module:project", ctx.getContextPath()); + assertEquals("module project", ctx.getContextPathWithSpaces()); + + // Access values from both levels + assertEquals("my-module", ctx.getParentValue("moduleName", String.class)); + assertEquals("my-project", ctx.getParentValue("name", String.class)); + assertTrue(ctx.getParentValue("verbose", Boolean.class, false)); + + // Pop one level + ctx.pop(); + assertEquals(1, ctx.depth()); + assertEquals("module", ctx.getContextPath()); + + // Pop to exit + ctx.pop(); + assertFalse(ctx.isInSubCommandMode()); + assertEquals("aesh> ", ctx.buildPrompt(true)); + } + + @Test + public void testFormatContextValues() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + CommandLineParser parser = new AeshCommandContainerBuilder<>() + .create(new ModuleGroupCommand<>()).getParser(); + + parser.parse("module --verbose --name=my-module-name", CommandLineParser.Mode.STRICT); + parser.getCommandPopulator().populateObject( + parser.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + + CommandContext ctx = new CommandContext("aesh> "); + ctx.push(parser, parser.getCommand()); + + String formatted = ctx.formatContextValues(); + assertTrue(formatted.contains("module")); + assertTrue(formatted.contains("name") || formatted.contains("my-module-name")); + assertTrue(formatted.contains("verbose")); + } + + @Test + public void testInheritedOptions() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Step 1: Parse and populate the parent command with inherited options + CommandLineParser parentParser = new AeshCommandContainerBuilder<>() + .create(new InheritedGroupCommand<>()).getParser(); + parentParser.parse("inherited --debug --level=5", CommandLineParser.Mode.STRICT); + parentParser.getCommandPopulator().populateObject( + parentParser.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + + InheritedGroupCommand parentCmd = + (InheritedGroupCommand) parentParser.getCommand(); + + // Verify parent values + assertTrue(parentCmd.debug); + assertEquals(Integer.valueOf(5), parentCmd.level); + + // Step 2: Create context with parent + CommandContext ctx = new CommandContext("aesh> "); + ctx.push(parentParser, parentCmd); + + // Step 3: Verify inherited values are available + assertTrue(ctx.getInheritedValue("debug", Boolean.class)); + assertEquals(Integer.valueOf(5), ctx.getInheritedValue("level", Integer.class)); + + // Step 4: Parse and populate a subcommand + CommandLineParser childParser = parentParser.getChildParser("sub"); + assertNotNull(childParser); + childParser.parse("sub --extra=extra-value", CommandLineParser.Mode.STRICT); + + // Populate with context - inherited values should be auto-populated + childParser.getCommandPopulator().populateObject( + childParser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE, ctx); + + InheritedSubCommand subCmd = + (InheritedSubCommand) childParser.getCommand(); + + // Verify subcommand's own option was populated + assertEquals("extra-value", subCmd.extra); + + // Verify inherited options were auto-populated (same field names) + assertTrue(subCmd.debug); + assertEquals(Integer.valueOf(5), subCmd.level); + } + + @Test + public void testInheritedOptionsNotOverridden() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Parse and populate the parent with inherited options + CommandLineParser parentParser = new AeshCommandContainerBuilder<>() + .create(new InheritedGroupCommand<>()).getParser(); + parentParser.parse("inherited --debug --level=5", CommandLineParser.Mode.STRICT); + parentParser.getCommandPopulator().populateObject( + parentParser.getProcessedCommand(), invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + + CommandContext ctx = new CommandContext("aesh> "); + ctx.push(parentParser, parentParser.getCommand()); + + // Parse subcommand with explicit value for an inherited option + CommandLineParser childParser = parentParser.getChildParser("sub"); + childParser.parse("sub --level=10 --extra=test", CommandLineParser.Mode.STRICT); + + childParser.getCommandPopulator().populateObject( + childParser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE, ctx); + + InheritedSubCommand subCmd = + (InheritedSubCommand) childParser.getCommand(); + + // User-specified value should NOT be overridden by inherited value + assertEquals(Integer.valueOf(10), subCmd.level); + + // debug should still be inherited (not explicitly set by user) + assertTrue(subCmd.debug); + } + + // ========== Test Command Classes ========== + + @GroupCommandDefinition(name = "module", description = "Module management", + groupCommands = {TagCommand.class}) + public static class ModuleGroupCommand implements Command { + + @Option(name = "verbose", shortName = 'v', hasValue = false, description = "Verbose mode") + public boolean verbose; + + @Option(name = "name", shortName = 'n', description = "Module name") + public String moduleName; + + public String getModuleName() { + return moduleName; + } + + public boolean isVerbose() { + return verbose; + } + + @Override + public CommandResult execute(CI commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "tag", description = "Manage tags") + public static class TagCommand implements Command { + + @ParentCommand + public ModuleGroupCommand parent; + + @Argument(description = "Tag name") + public String tagName; + + @Override + public CommandResult execute(CI commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @GroupCommandDefinition(name = "project", description = "Project management", + groupCommands = {}) + public static class ProjectGroupCommand implements Command { + + @Option(name = "name", shortName = 'n', description = "Project name") + public String name; + + @Override + public CommandResult execute(CI commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + // ========== Inherited Options Test Commands ========== + + @GroupCommandDefinition(name = "inherited", description = "Test inherited options", + groupCommands = {InheritedSubCommand.class}) + public static class InheritedGroupCommand implements Command { + + @Option(name = "debug", shortName = 'd', hasValue = false, description = "Debug mode", inherited = true) + public boolean debug; + + @Option(name = "level", shortName = 'l', description = "Log level", inherited = true) + public Integer level; + + @Option(name = "config", shortName = 'c', description = "Config file (not inherited)") + public String config; + + @Override + public CommandResult execute(CI commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "sub", description = "Subcommand with inherited options") + public static class InheritedSubCommand implements Command { + + // These options match the parent's inherited options and will be auto-populated + @Option(name = "debug", shortName = 'd', hasValue = false, description = "Debug mode") + public boolean debug; + + @Option(name = "level", shortName = 'l', description = "Log level") + public Integer level; + + // This is a subcommand-specific option + @Option(name = "extra", shortName = 'e', description = "Extra option") + public String extra; + + @Override + public CommandResult execute(CI commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } +} diff --git a/aesh/src/test/java/org/aesh/command/settings/SubCommandModeSettingsTest.java b/aesh/src/test/java/org/aesh/command/settings/SubCommandModeSettingsTest.java new file mode 100644 index 00000000..b565db63 --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/settings/SubCommandModeSettingsTest.java @@ -0,0 +1,135 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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.aesh.command.settings; + +import org.aesh.command.impl.context.CommandContext; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Tests for SubCommandModeSettings. + */ +public class SubCommandModeSettingsTest { + + @Test + public void testDefaultSettings() { + SubCommandModeSettings settings = SubCommandModeSettings.defaults(); + + assertTrue(settings.isEnabled()); + assertEquals("exit", settings.getExitCommand()); + assertEquals("..", settings.getAlternativeExitCommand()); + assertEquals(":", settings.getContextSeparator()); + assertTrue(settings.showContextOnEntry()); + assertTrue(settings.showArgumentInPrompt()); + assertEquals("context", settings.getContextCommand()); + assertEquals("Entering {name} mode.", settings.getEnterMessage()); + assertNull(settings.getExitMessage()); + assertEquals("Type '{exit}' to return.", settings.getExitHint()); + assertTrue(settings.exitOnCtrlC()); + } + + @Test + public void testBuilder() { + SubCommandModeSettings settings = SubCommandModeSettings.builder() + .enabled(false) + .exitCommand("quit") + .alternativeExitCommand("back") + .contextSeparator("/") + .showContextOnEntry(false) + .showArgumentInPrompt(false) + .contextCommand(null) + .enterMessage("Entering {name}...") + .exitMessage("Leaving {name}.") + .exitHint("Use '{exit}' or '{alt}' to exit.") + .exitOnCtrlC(false) + .build(); + + assertFalse(settings.isEnabled()); + assertEquals("quit", settings.getExitCommand()); + assertEquals("back", settings.getAlternativeExitCommand()); + assertEquals("/", settings.getContextSeparator()); + assertFalse(settings.showContextOnEntry()); + assertFalse(settings.showArgumentInPrompt()); + assertNull(settings.getContextCommand()); + assertEquals("Entering {name}...", settings.getEnterMessage()); + assertEquals("Leaving {name}.", settings.getExitMessage()); + assertEquals("Use '{exit}' or '{alt}' to exit.", settings.getExitHint()); + assertFalse(settings.exitOnCtrlC()); + } + + @Test + public void testCommandContextWithSettings() { + SubCommandModeSettings settings = SubCommandModeSettings.builder() + .exitCommand("quit") + .alternativeExitCommand("back") + .contextSeparator("/") + .build(); + + CommandContext ctx = new CommandContext("test> ", settings); + + // Test exit command detection + assertTrue(ctx.isExitCommand("quit")); + assertTrue(ctx.isExitCommand("back")); + assertFalse(ctx.isExitCommand("exit")); + assertFalse(ctx.isExitCommand("")); + assertFalse(ctx.isExitCommand(null)); + } + + @Test + public void testMessageFormatting() { + SubCommandModeSettings settings = SubCommandModeSettings.builder() + .enterMessage("Welcome to {name}!") + .exitMessage("Goodbye from {name}.") + .exitHint("Press '{exit}' or '{alt}' to leave.") + .exitCommand("quit") + .alternativeExitCommand("back") + .build(); + + CommandContext ctx = new CommandContext("test> ", settings); + + assertEquals("Welcome to project!", ctx.formatEnterMessage("project")); + assertEquals("Goodbye from project.", ctx.formatExitMessage("project")); + assertEquals("Press 'quit' or 'back' to leave.", ctx.formatExitHint()); + } + + @Test + public void testSettingsIntegrationWithMainSettings() { + SubCommandModeSettings subSettings = SubCommandModeSettings.builder() + .exitCommand("bye") + .build(); + + Settings settings = SettingsBuilder.builder() + .subCommandModeSettings(subSettings) + .build(); + + assertEquals("bye", settings.subCommandModeSettings().getExitCommand()); + } + + @Test + public void testDefaultSettingsFromMainSettings() { + Settings settings = SettingsBuilder.builder().build(); + + // Should return default settings + assertNotNull(settings.subCommandModeSettings()); + assertTrue(settings.subCommandModeSettings().isEnabled()); + assertEquals("exit", settings.subCommandModeSettings().getExitCommand()); + } +} diff --git a/examples/src/main/java/examples/ParentCommandExample.java b/examples/src/main/java/examples/ParentCommandExample.java new file mode 100644 index 00000000..71c6ee32 --- /dev/null +++ b/examples/src/main/java/examples/ParentCommandExample.java @@ -0,0 +1,425 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * + * http://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 examples; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandException; +import org.aesh.command.CommandResult; +import org.aesh.command.GroupCommandDefinition; +import org.aesh.command.activator.CommandActivator; +import org.aesh.command.activator.OptionActivator; +import org.aesh.command.completer.CompleterInvocation; +import org.aesh.command.converter.ConverterInvocation; +import org.aesh.command.impl.registry.AeshCommandRegistryBuilder; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.option.Option; +import org.aesh.command.option.ParentCommand; +import org.aesh.command.parser.CommandLineParserException; +import org.aesh.command.registry.CommandRegistry; +import org.aesh.command.registry.CommandRegistryException; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.command.settings.SubCommandModeSettings; +import org.aesh.command.validator.ValidatorInvocation; +import org.aesh.console.ReadlineConsole; +import org.aesh.readline.Prompt; +import org.aesh.readline.terminal.formatting.Color; +import org.aesh.readline.terminal.formatting.TerminalColor; +import org.aesh.readline.terminal.formatting.TerminalString; + +import java.io.IOException; + +/** + * Example demonstrating parent context access in subcommands. + * + * This example shows how subcommands can access values from their parent group command + * using three different approaches: + * 1. Using @ParentCommand annotation for direct field injection + * 2. Using CommandInvocation.getParentValue() for programmatic access + * 3. Using inherited=true on parent options for automatic field population + * + * === SUB-COMMAND MODE (recommended) === + * Enter sub-command mode first, then run subcommands: + * project --name=myapp --verbose (enters sub-command mode, prompt changes to "project[myapp]> ") + * build (inherits --verbose via inherited=true) + * test --coverage (inherits parent options) + * status (uses inherited --verbose option) + * context (displays current context values) + * exit (returns to main prompt, or use "..") + * + * === DIRECT INVOCATION (alternative) === + * When calling subcommands directly, use the --name option on the subcommand: + * project build --name=myapp + * project test --name=myapp --coverage + * project deploy --name=myapp --env=production + * + * @author Ståle W. Pedersen + */ +public class ParentCommandExample { + + public static void main(String[] args) throws CommandLineParserException, IOException, CommandRegistryException { + + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(ExitCommand.class) + .command(ProjectCommand.class) + .create(); + + // Configure sub-command mode settings (optional - defaults work well for most cases) + // You can customize exit commands, prompts, messages, etc. + SubCommandModeSettings subCommandSettings = SubCommandModeSettings.builder() + .exitCommand("exit") // Primary exit command (default: "exit") + .alternativeExitCommand("..") // Alternative exit command (default: "..") + .contextSeparator(":") // Separator for nested contexts (default: ":") + .showArgumentInPrompt(true) // Show option value in prompt (default: true) + .enterMessage("Entering {name} mode.") // Message when entering (default) + .exitHint("Type '{exit}' or '{alt}' to return.") // Exit hint + .exitOnCtrlC(true) // Ctrl+C exits sub-command mode (default: true) + .build(); + + SettingsBuilder builder = + SettingsBuilder.builder() + .logging(true) + .commandRegistry(registry) + .subCommandModeSettings(subCommandSettings); + + ReadlineConsole console = new ReadlineConsole(builder.build()); + console.setPrompt(new Prompt(new TerminalString("[parent-example]$ ", + new TerminalColor(Color.CYAN, Color.DEFAULT, Color.Intensity.BRIGHT)))); + + console.start(); + } + + @CommandDefinition(name = "exit", description = "Exit the application", aliases = {"quit"}) + public static class ExitCommand implements Command { + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + commandInvocation.stop(); + return CommandResult.SUCCESS; + } + } + + /** + * Parent group command that defines project-level options. + * Subcommands can access these options via @ParentCommand or CommandInvocation. + * + * When executed without a subcommand, enters sub-command mode where + * subsequent commands have access to the project's options. + */ + @GroupCommandDefinition(name = "project", description = "Project management commands", + groupCommands = {BuildCommand.class, TestCommand.class, DeployCommand.class, StatusCommand.class}) + public static class ProjectCommand implements Command { + + @Option(name = "name", shortName = 'n', required = true, description = "Project name") + private String projectName; + + // inherited=true makes this option automatically available to subcommands + // Subcommands with a "verbose" field will get the value auto-populated + @Option(name = "verbose", shortName = 'v', hasValue = false, description = "Enable verbose output", inherited = true) + private boolean verbose; + + @Option(name = "config", shortName = 'c', description = "Configuration file path") + private String configFile; + + // Getters for subcommands to access + public String getProjectName() { + return projectName; + } + + public boolean isVerbose() { + return verbose; + } + + public String getConfigFile() { + return configFile; + } + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + // Display current settings + invocation.println("Project: " + projectName); + invocation.println("Verbose: " + verbose); + if (configFile != null) { + invocation.println("Config: " + configFile); + } + invocation.println(""); + + // Enter sub-command mode - this pushes the current command onto the context + // Subsequent commands (build, test, deploy) will have access to projectName, verbose, etc. + if (invocation.enterSubCommandMode(this)) { + invocation.println("Available subcommands: build, test, deploy"); + } else { + invocation.println("Sub-command mode not available."); + invocation.println("Use: project build --name="); + } + + return CommandResult.SUCCESS; + } + } + + /** + * Build subcommand - demonstrates @ParentCommand annotation. + * The parent ProjectCommand is automatically injected when in sub-command mode. + * + * Usage: + * 1. Enter sub-command mode: project --name=myapp --verbose + * 2. Then run: build --target=jar + * + * Or use direct invocation with local options: + * project build --name=myapp --target=jar + */ + @CommandDefinition(name = "build", description = "Build the project") + public static class BuildCommand implements Command { + + // Parent command is injected when in sub-command mode + @ParentCommand + private ProjectCommand parent; + + // Local option that can also be used for direct invocation + @Option(name = "name", shortName = 'n', description = "Project name (use when not in sub-command mode)") + private String name; + + @Option(name = "target", shortName = 't', defaultValue = {"jar"}, description = "Build target (jar, war, native)") + private String target; + + @Option(name = "skip-tests", hasValue = false, description = "Skip running tests during build") + private boolean skipTests; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + // Get project name from parent (if in sub-command mode) or from local option + String projectName = (parent != null) ? parent.getProjectName() : name; + boolean verbose = (parent != null) && parent.isVerbose(); + + if (projectName == null) { + invocation.println("Error: Project name is required."); + invocation.println("Usage: project build --name="); + invocation.println(" Or: Enter sub-command mode first with: project --name="); + return CommandResult.FAILURE; + } + + invocation.println("=== Building Project ==="); + invocation.println("Project: " + projectName); + invocation.println("Target: " + target); + invocation.println("Skip tests: " + skipTests); + + if (verbose) { + String configFile = parent.getConfigFile(); + invocation.println("\n[VERBOSE] Build configuration:"); + invocation.println("[VERBOSE] Config file: " + configFile); + invocation.println("[VERBOSE] Starting build process..."); + invocation.println("[VERBOSE] Compiling sources..."); + invocation.println("[VERBOSE] Packaging " + target + "..."); + } + + invocation.println("\nBuild completed successfully!"); + return CommandResult.SUCCESS; + } + } + + /** + * Test subcommand - demonstrates CommandInvocation.getParentValue() approach. + * This is useful when you don't want a direct dependency on the parent class. + * + * Usage: + * 1. Enter sub-command mode: project --name=myapp --verbose + * 2. Then run: test --coverage + * + * Or use direct invocation with local options: + * project test --name=myapp --coverage + */ + @CommandDefinition(name = "test", description = "Run project tests") + public static class TestCommand implements Command { + + // Local option for direct invocation + @Option(name = "name", shortName = 'n', description = "Project name (use when not in sub-command mode)") + private String name; + + @Option(name = "coverage", hasValue = false, description = "Generate coverage report") + private boolean coverage; + + @Option(name = "filter", description = "Filter tests by pattern") + private String filter; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + // Try to get parent values via CommandInvocation (works in sub-command mode) + String projectName = invocation.getParentValue("projectName", String.class); + Boolean verbose = invocation.getParentValue("verbose", Boolean.class, false); + + // Fall back to local option if not in sub-command mode + if (projectName == null) { + projectName = name; + } + + if (projectName == null) { + invocation.println("Error: Project name is required."); + invocation.println("Usage: project test --name="); + invocation.println(" Or: Enter sub-command mode first with: project --name="); + return CommandResult.FAILURE; + } + + invocation.println("=== Running Tests ==="); + invocation.println("Project: " + projectName); + invocation.println("Coverage: " + coverage); + if (filter != null) { + invocation.println("Filter: " + filter); + } + + if (verbose) { + invocation.println("\n[VERBOSE] Test configuration:"); + invocation.println("[VERBOSE] Running all test suites..."); + if (filter != null) { + invocation.println("[VERBOSE] Applying filter: " + filter); + } + if (coverage) { + invocation.println("[VERBOSE] Generating coverage report..."); + } + } + + invocation.println("\nAll tests passed!"); + return CommandResult.SUCCESS; + } + } + + /** + * Deploy subcommand - demonstrates both approaches combined. + * + * Usage: + * 1. Enter sub-command mode: project --name=myapp --verbose + * 2. Then run: deploy --env=production + * + * Or use direct invocation with local options: + * project deploy --name=myapp --env=production + */ + @CommandDefinition(name = "deploy", description = "Deploy the project") + public static class DeployCommand implements Command { + + // Direct injection for type-safe access (works in sub-command mode) + @ParentCommand + private ProjectCommand parent; + + // Local option for direct invocation + @Option(name = "name", shortName = 'n', description = "Project name (use when not in sub-command mode)") + private String name; + + @Option(name = "env", shortName = 'e', required = true, description = "Target environment (dev, staging, production)") + private String environment; + + @Option(name = "dry-run", hasValue = false, description = "Simulate deployment without making changes") + private boolean dryRun; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + // Get project name from parent (if in sub-command mode) or from local option + String projectName = (parent != null) ? parent.getProjectName() : name; + boolean verbose = (parent != null) && parent.isVerbose(); + + if (projectName == null) { + invocation.println("Error: Project name is required."); + invocation.println("Usage: project deploy --name= --env="); + invocation.println(" Or: Enter sub-command mode first with: project --name="); + return CommandResult.FAILURE; + } + + invocation.println("=== Deploying Project ==="); + invocation.println("Project: " + projectName); + invocation.println("Environment: " + environment); + invocation.println("Dry run: " + dryRun); + + // Can also use CommandInvocation for specific values (works in sub-command mode) + String configFile = invocation.getParentValue("configFile", String.class); + if (configFile != null) { + invocation.println("Using config: " + configFile); + } + + if (verbose) { + invocation.println("\n[VERBOSE] Deployment steps:"); + invocation.println("[VERBOSE] 1. Building artifact..."); + invocation.println("[VERBOSE] 2. Uploading to " + environment + " server..."); + invocation.println("[VERBOSE] 3. Restarting services..."); + if (dryRun) { + invocation.println("[VERBOSE] (Dry run - no actual changes made)"); + } + } + + if (dryRun) { + invocation.println("\nDry run completed - no changes made."); + } else { + invocation.println("\nDeployment to " + environment + " completed successfully!"); + } + + return CommandResult.SUCCESS; + } + } + + /** + * Status subcommand - demonstrates inherited option auto-population. + * + * The "verbose" field will be automatically populated from the parent's + * inherited verbose option when in sub-command mode. + * + * This approach is cleaner than @ParentCommand or getParentValue() when you + * just need specific option values and want them auto-populated. + * + * Usage: + * 1. Enter sub-command mode: project --name=myapp --verbose + * 2. Then run: status + * (verbose field is auto-populated from parent) + */ + @CommandDefinition(name = "status", description = "Show project status") + public static class StatusCommand implements Command { + + // This field has the same name as the parent's inherited option. + // It will be automatically populated when in sub-command mode. + @Option(name = "verbose", shortName = 'v', hasValue = false, description = "Show detailed status") + private boolean verbose; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + invocation.println("=== Project Status ==="); + + // Get the project name from parent context + String projectName = invocation.getParentValue("projectName", String.class); + if (projectName != null) { + invocation.println("Project: " + projectName); + } + + invocation.println("Status: Active"); + invocation.println("Health: Good"); + + // The verbose field was auto-populated from parent's inherited option + if (verbose) { + invocation.println("\n[VERBOSE] Detailed status:"); + invocation.println("[VERBOSE] Last build: 2 hours ago"); + invocation.println("[VERBOSE] Test coverage: 87%"); + invocation.println("[VERBOSE] Dependencies: 42 (3 outdated)"); + invocation.println("[VERBOSE] Code quality: A"); + + // Also demonstrate getInheritedValue for explicit inherited value access + Boolean inheritedVerbose = invocation.getInheritedValue("verbose", Boolean.class); + invocation.println("[VERBOSE] (verbose inherited from parent: " + inheritedVerbose + ")"); + } + + return CommandResult.SUCCESS; + } + } +} diff --git a/pom.xml b/pom.xml index 01f69014..41c24a98 100644 --- a/pom.xml +++ b/pom.xml @@ -35,7 +35,6 @@ aesh - examples