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 a3238a4c..a262a660 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 @@ -63,10 +63,144 @@ public class ProcessedCommand, 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: + *
    + *
  1. an explicit override: the {@code aesh.help.width} system property, + * then the {@code COLUMNS} environment variable (as many CLIs honour);
  2. + *
  3. 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;
  4. + *
  5. the default {@link #HELP_WIDTH}.
  6. + *
+ * 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}). + *

+ * 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/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 " " + 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 = '='; @@ -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 opts = getDisplayOptions(); List visibleOpts = new ArrayList<>(opts.size()); 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 6b8e0716..249ca412 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 @@ -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 diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java b/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java index 9c25b97d..6399ef74 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java @@ -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 diff --git a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java index 6dc09fb5..466269fd 100644 --- a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java +++ b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java @@ -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; @@ -1228,7 +1232,7 @@ public String getFormattedCommand(int offset, int descriptionStart) { ""); List 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); diff --git a/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java new file mode 100644 index 00000000..516d5932 --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java @@ -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 command) throws Exception { + CommandLineParser parser = new AeshCommandContainerBuilder() + .create(command).getParser(); + parser.updateAnsiMode(false); + return parser.printHelp(); + } + + private static ProcessedCommand processedCommand(Command command) throws Exception { + return new AeshCommandContainerBuilder().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 { + @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; + } + } +}