Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,144 @@
public class ProcessedCommand<C extends Command<CI>, CI extends CommandInvocation> {

/**
* The width the help is written for: longer descriptions are wrapped.
* The default width the help is written for when nothing else resolves it:
* longer descriptions are wrapped. See {@link #helpWidth()}.
*/
public static final int HELP_WIDTH = 80;

/** Lower bound for a resolved help width, so a tiny value never garbles wrapping. */
private static final int MIN_HELP_WIDTH = 40;

/**
* Optional, lazily-consulted source of the live terminal width, queried
* only while help is being rendered (see {@link #helpWidth()}). It is set
* by whoever prints usage and has a real terminal, and is {@code transient}
* on purpose: it is never part of the command definition, only a per-render
* hint. Callers with no terminal (documentation generation, programmatic
* {@code printHelp}) leave it null and so never touch a terminal.
*/
private transient java.util.function.IntSupplier terminalWidthSupplier;

/**
* Install a terminal-width source, consulted lazily by {@link #helpWidth()}
* the next time help is rendered. Pass {@code null} to clear it.
*/
public void setTerminalWidthSupplier(java.util.function.IntSupplier terminalWidthSupplier) {
this.terminalWidthSupplier = terminalWidthSupplier;
}

/** The terminal-width source, or null; used to propagate it to child commands. */
public java.util.function.IntSupplier terminalWidthSupplier() {
return terminalWidthSupplier;
}

/**
* The width this command's help should wrap to, resolved at render time.
* Resolution order:
* <ol>
* <li>an explicit override: the {@code aesh.help.width} system property,
* then the {@code COLUMNS} environment variable (as many CLIs honour);</li>
* <li>the live terminal width, but only if a supplier was installed via
* {@link #setTerminalWidthSupplier}, and it is queried here and only here
* (when usage actually prints) and guarded so a missing or misbehaving
* terminal falls through instead of breaking help;</li>
* <li>the default {@link #HELP_WIDTH}.</li>
* </ol>
* Any resolved value is clamped to at least {@link #MIN_HELP_WIDTH}.
*
* @return the help width in columns
*/
public int helpWidth() {
int override = overrideWidth();
if (override > 0)
return override;
java.util.function.IntSupplier supplier = terminalWidthSupplier;
if (supplier != null) {
try {
int width = supplier.getAsInt();
if (width > 0)
return Math.max(width, MIN_HELP_WIDTH);
} catch (Throwable ignored) {
// No terminal, or it misbehaved: fall through to the default.
}
}
return HELP_WIDTH;
}

/**
* The help width from an explicit override only ({@code aesh.help.width}
* system property, then {@code COLUMNS}), or {@link #HELP_WIDTH} when none
* is set. Does not consult any terminal, so it is safe where there is no
* render context.
*
* @return the help width in columns
*/
public static int resolveHelpWidth() {
int override = overrideWidth();
return override > 0 ? override : HELP_WIDTH;
}

private static int overrideWidth() {
int width = parseWidth(System.getProperty("aesh.help.width"));
if (width <= 0)
width = parseWidth(System.getenv("COLUMNS"));
return width > 0 ? Math.max(width, MIN_HELP_WIDTH) : -1;
}

private static int parseWidth(String value) {
if (value == null)
return -1;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return -1;
}
}

/**
* Read the width of the controlling terminal by asking it directly, so it
* works even when stdin or stdout is redirected (e.g. a launcher that
* captures the command's stdout). On POSIX this runs {@code stty size}
* against {@code /dev/tty} and returns its column count; everything is
* guarded and time-boxed, so no terminal, no {@code stty}, or any failure
* simply yields {@code -1}. Not supported on Windows (returns {@code -1}).
* <p>
* This is deliberately an on-demand probe: call it only when usage is
* actually printed, never eagerly, and wire it through
* {@link #setTerminalWidthSupplier}.
*
* @return the controlling terminal width in columns, or {@code -1}
*/
public static int detectTerminalWidth() {
if (Config.isWindows())
return -1;
Process process = null;
try {
process = new ProcessBuilder("sh", "-c", "stty size </dev/tty 2>/dev/null")
.redirectErrorStream(false)
.start();
String line;
try (java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()))) {
line = reader.readLine();
}
if (!process.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
return -1;
}
if (line != null) {
// stty size prints "<rows> <cols>"
String[] parts = line.trim().split("\\s+");
if (parts.length == 2)
return parseWidth(parts[1]);
}
} catch (Throwable ignored) {
if (process != null)
process.destroyForcibly();
}
return -1;
}

private static final Pattern DESCRIPTION_VARIABLE_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
private static final char DASH = '-';
private static final char EQUALS = '=';
Expand Down Expand Up @@ -1380,7 +1514,7 @@ public String printHelp(String commandName, boolean supportsHyperlinks) {
public String printHelp(String commandName, boolean supportsHyperlinks, boolean showAll) {
int maxLength = 0;
int maxOptionColumnWidth = 24;
int width = HELP_WIDTH;
int width = helpWidth();
DescriptionResolver descriptionResolver = new DescriptionResolver(name, commandName, null, null, null);
List<ProcessedOption> opts = getDisplayOptions();
List<ProcessedOption> visibleOpts = new ArrayList<>(opts.size());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,27 @@ public String getHelpInfo(String commandName) {

@Override
public String getHelpInfo() {
return commandContainer.getParser().parsedCommand().printHelp();
org.aesh.command.impl.parser.CommandLineParser<?> parser = commandContainer.getParser().parsedCommand();
// Resolve help to the live terminal width when printing interactively.
// The supplier is consulted lazily inside printHelp (only when usage
// actually prints) and is guarded, so a non-terminal shell or a size()
// failure simply falls back to the default width.
parser.getProcessedCommand().setTerminalWidthSupplier(this::terminalWidth);
return parser.printHelp();
}

private int terminalWidth() {
// Ask the controlling terminal directly first: it is correct even when
// the command's stdout is captured/redirected (so the shell's own
// terminal looks absent). Fall back to the shell's reported size.
int width = org.aesh.command.impl.internal.ProcessedCommand.detectTerminalWidth();
if (width > 0)
return width;
try {
return shell.size().getWidth();
} catch (Throwable t) {
return -1;
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,24 @@ public String getHelpInfo(String commandName) {

@Override
public String getHelpInfo() {
return commandContainer.getParser().parsedCommand().printHelp();
org.aesh.command.impl.parser.CommandLineParser<?> parser = commandContainer.getParser().parsedCommand();
// Resolve help to the live terminal width, consulted lazily (only now,
// when usage prints) and guarded: probe the controlling terminal first
// so it is correct even when stdout is captured/redirected, then fall
// back to the shell's reported size, then the default width.
parser.getProcessedCommand().setTerminalWidthSupplier(this::terminalWidth);
return parser.printHelp();
}

private int terminalWidth() {
int width = org.aesh.command.impl.internal.ProcessedCommand.detectTerminalWidth();
if (width > 0)
return width;
try {
return shell.size().getWidth();
} catch (Throwable t) {
return -1;
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,10 @@ public String printHelp() {
int maxLength = 0;
if (hasChildren) {
for (CommandLineParser child : parsers) {
// Children format their own one-line entry; let them resolve
// the same width as this command (e.g. the live terminal).
child.getProcessedCommand().setTerminalWidthSupplier(
processedCommand.terminalWidthSupplier());
int length = child.getProcessedCommand().name().length();
if (length > maxLength)
maxLength = length;
Expand Down Expand Up @@ -1228,7 +1232,7 @@ public String getFormattedCommand(int offset, int descriptionStart) {
"");
List<String> lines = ProcessedOption.wrapText(
resolveDescriptionVariables(getProcessedCommand().description(), null),
ProcessedCommand.HELP_WIDTH - pad.length());
getProcessedCommand().helpWidth() - pad.length());
for (int i = 0; i < lines.size(); i++) {
if (i > 0)
ansiBuilder.append(Config.getLineSeparator()).append(pad);
Expand Down
141 changes: 141 additions & 0 deletions aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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
*/
package org.aesh.command.help;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.impl.container.AeshCommandContainerBuilder;
import org.aesh.command.impl.internal.ProcessedCommand;
import org.aesh.command.impl.parser.CommandLineParser;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.Option;
import org.junit.After;
import org.junit.Test;

/**
* The help width defaults to {@link ProcessedCommand#HELP_WIDTH} but can be
* overridden with the {@code aesh.help.width} system property (or the
* {@code COLUMNS} environment variable), clamped to a sane minimum.
*/
public class HelpWidthTest {

@After
public void clearProperty() {
System.clearProperty("aesh.help.width");
}

@Test
public void testDefaultWidthWhenNoOverride() {
System.clearProperty("aesh.help.width");
assertEquals(ProcessedCommand.HELP_WIDTH, ProcessedCommand.resolveHelpWidth());
}

@Test
public void testSystemPropertyOverride() {
System.setProperty("aesh.help.width", "120");
assertEquals(120, ProcessedCommand.resolveHelpWidth());
}

@Test
public void testTinyOverrideIsClamped() {
System.setProperty("aesh.help.width", "5");
assertTrue("a tiny width is clamped up, got: " + ProcessedCommand.resolveHelpWidth(),
ProcessedCommand.resolveHelpWidth() >= 40);
}

@Test
public void testNonNumericOverrideIgnored() {
System.setProperty("aesh.help.width", "wide");
assertEquals(ProcessedCommand.HELP_WIDTH, ProcessedCommand.resolveHelpWidth());
}

@Test
public void testTerminalWidthSupplierUsedWhenNoOverride() throws Exception {
ProcessedCommand<?, ?> pc = processedCommand(new WideCommand());
pc.setTerminalWidthSupplier(() -> 100);
assertEquals(100, pc.helpWidth());
}

@Test
public void testOverrideBeatsTerminalWidthSupplier() throws Exception {
System.setProperty("aesh.help.width", "120");
ProcessedCommand<?, ?> pc = processedCommand(new WideCommand());
pc.setTerminalWidthSupplier(() -> 100);
assertEquals(120, pc.helpWidth());
}

@Test
public void testSupplierFailureFallsBackToDefault() throws Exception {
ProcessedCommand<?, ?> pc = processedCommand(new WideCommand());
pc.setTerminalWidthSupplier(() -> {
throw new IllegalStateException("no terminal");
});
assertEquals(ProcessedCommand.HELP_WIDTH, pc.helpWidth());
}

@Test
public void testSupplierNonPositiveFallsBackToDefault() throws Exception {
ProcessedCommand<?, ?> pc = processedCommand(new WideCommand());
pc.setTerminalWidthSupplier(() -> 0);
assertEquals(ProcessedCommand.HELP_WIDTH, pc.helpWidth());
}

@Test
public void testSupplierTinyWidthIsClamped() throws Exception {
ProcessedCommand<?, ?> pc = processedCommand(new WideCommand());
pc.setTerminalWidthSupplier(() -> 5);
assertTrue("tiny terminal width clamped, got: " + pc.helpWidth(), pc.helpWidth() >= 40);
}

@Test
public void testDetectTerminalWidthIsSafeWithoutTerminal() {
// In a non-terminal test/CI environment this must not throw and must
// yield -1 (no tty) or a positive width; never zero or an exception.
int width = ProcessedCommand.detectTerminalWidth();
assertTrue("detectTerminalWidth returned " + width, width == -1 || width > 0);
}

@Test
public void testDescriptionWrapsAtConfiguredWidth() throws Exception {
System.setProperty("aesh.help.width", "120");
String wide = help(new WideCommand());
System.setProperty("aesh.help.width", "40");
String narrow = help(new WideCommand());

// The same help wraps onto more lines when the width is smaller.
assertTrue("narrow help should wrap onto more lines than wide help\nwide:\n" + wide
+ "\nnarrow:\n" + narrow,
narrow.split("\\R").length > wide.split("\\R").length);
}

private static String help(Command<CommandInvocation> command) throws Exception {
CommandLineParser<CommandInvocation> parser = new AeshCommandContainerBuilder<CommandInvocation>()
.create(command).getParser();
parser.updateAnsiMode(false);
return parser.printHelp();
}

private static ProcessedCommand<?, ?> processedCommand(Command<CommandInvocation> command) throws Exception {
return new AeshCommandContainerBuilder<CommandInvocation>().create(command).getParser().getProcessedCommand();
}

@CommandDefinition(name = "wide", description = "This command has a deliberately long description so that it wraps differently depending on the configured help width used by the renderer.", generateHelp = true)
public static class WideCommand implements Command<CommandInvocation> {
@Option(description = "A fairly long option description that also wraps when the help width is made narrow enough to force it")
String value;

@Override
public CommandResult execute(CommandInvocation invocation) {
return CommandResult.SUCCESS;
}
}
}
Loading