Skip to content
17 changes: 17 additions & 0 deletions Sources/ArgumentParser/Parsable Types/ParsableArguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ extension ParsableArguments {
MessageInfo(error: error, type: self).fullText(for: self)
}

/// Returns a full message for the given error, including usage information,
/// if appropriate.
///
/// - Parameters:
/// - error: An error to generate a message for.
/// - columns: The column width to use when wrapping long line in the
/// help screen. If `columns` is `nil`, uses the current terminal
/// width, or a default value of `80` if the terminal width is not
/// available.
/// - Returns: A message that can be displayed to the user.
public static func fullMessage(
for error: Error,
columns: Int?
) -> String {
MessageInfo(error: error, type: self, columns: columns).fullText(for: self)
}

/// Returns the text of the help screen for this type.
///
/// - Parameters:
Expand Down
10 changes: 5 additions & 5 deletions Sources/ArgumentParser/Usage/MessageInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ enum MessageInfo {
case validation(message: String, usage: String, help: String)
case other(message: String, exitCode: ExitCode)

init(error: Error, type: ParsableArguments.Type) {
init(error: Error, type: ParsableArguments.Type, columns: Int? = nil) {
var commandStack: [ParsableCommand.Type]
var parserError: ParserError? = nil

Expand All @@ -29,7 +29,7 @@ enum MessageInfo {
// Exit early on built-in requests
switch e.parserError {
case .helpRequested(let visibility):
self = .help(text: HelpGenerator(commandStack: e.commandStack, visibility: visibility).rendered())
self = .help(text: HelpGenerator(commandStack: e.commandStack, visibility: visibility).rendered(screenWidth: columns))
return

case .dumpHelpRequested:
Expand Down Expand Up @@ -64,7 +64,7 @@ enum MessageInfo {

case let e as ParserError:
// Send ParserErrors back through the CommandError path
self.init(error: CommandError(commandStack: [type.asCommand], parserError: e), type: type)
self.init(error: CommandError(commandStack: [type.asCommand], parserError: e), type: type, columns: columns)
return

default:
Expand Down Expand Up @@ -97,7 +97,7 @@ enum MessageInfo {
if let command = command {
commandStack = CommandParser(type.asCommand).commandStack(for: command)
}
self = .help(text: HelpGenerator(commandStack: commandStack, visibility: .default).rendered())
self = .help(text: HelpGenerator(commandStack: commandStack, visibility: .default).rendered(screenWidth: columns))
case .dumpRequest(let command):
if let command = command {
commandStack = CommandParser(type.asCommand).commandStack(for: command)
Expand All @@ -120,7 +120,7 @@ enum MessageInfo {
} else if let parserError = parserError {
let usage: String = {
guard case ParserError.noArguments = parserError else { return usage }
return "\n" + HelpGenerator(commandStack: [type.asCommand], visibility: .default).rendered()
return "\n" + HelpGenerator(commandStack: [type.asCommand], visibility: .default).rendered(screenWidth: columns)
}()
let argumentSet = ArgumentSet(commandStack.last!, visibility: .default, parent: nil)
let message = argumentSet.errorDescription(error: parserError) ?? ""
Expand Down
97 changes: 82 additions & 15 deletions Sources/ArgumentParser/Utilities/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,71 @@ func ioctl(_ a: Int32, _ b: Int32, _ p: UnsafeMutableRawPointer) -> Int32 {

extension Platform {
/// The default terminal size.
static var defaultTerminalSize: (width: Int, height: Int) {
(80, 25)
private static var defaultTerminalSize: (width: Int, height: Int) {
(width: 80, height: 25)
}

/// Returns the current terminal size, or the default if the size is
/// unavailable.
static func terminalSize() -> (width: Int, height: Int) {
/// The terminal size specified by the COLUMNS and LINES overrides
/// (if present).
///
/// Per the [Linux environ(7) manpage][linenv]:
///
/// ```
/// * COLUMNS and LINES tell applications about the window size,
/// possibly overriding the actual size.
/// ```
///
/// And the [FreeBSD environ(7) version][bsdenv]:
///
/// ```
/// COLUMNS The user's preferred width in column positions for the
/// terminal. Utilities such as ls(1) and who(1) use this
/// to format output into columns. If unset or empty,
/// utilities will use an ioctl(2) call to ask the termi-
/// nal driver for the width.
/// ```
///
/// > Note: Always returns `(nil, nil)` on Windows and WASI.
///
/// - Returns: A tuple consisting of a width found in the `COLUMNS` environment
/// variable (or `nil` if the variable is not present) and a height found in
/// the `LINES` environment variable (or `nil` if that variable is not present).
///
/// [linenv]: https://man7.org/linux/man-pages/man7/environ.7.html:~:text=COLUMNS
/// [bsdenv]: https://man.freebsd.org/cgi/man.cgi?environ(7)#:~:text=COLUMNS
private static func userSpecifiedTerminalSize() -> (width: Int?, height: Int?) {
var width: Int? = nil, height: Int? = nil

#if !os(Windows) && !os(WASI)
if let colsCStr = getenv("COLUMNS"), let colsVal = Int(String(cString: colsCStr)) {
width = colsVal
}
if let linesCStr = getenv("LINES"), let linesVal = Int(String(cString: linesCStr)) {
height = linesVal
}
#endif

return (width: width, height: height)
}

/// The current terminal size as reported by the windowing system,
/// if available.
///
/// Returns (nil, nil) if no reported size is available.
private static func reportedTerminalSize() -> (width: Int?, height: Int?) {
#if os(WASI)
// WASI doesn't yet support terminal size
return defaultTerminalSize
return (width: nil, height: nil)
#elseif os(Windows)
var csbi: CONSOLE_SCREEN_BUFFER_INFO = CONSOLE_SCREEN_BUFFER_INFO()
var csbi = CONSOLE_SCREEN_BUFFER_INFO()
guard GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi) else {
return defaultTerminalSize
return (width: nil, height: nil)
}
return (width: Int(csbi.srWindow.Right - csbi.srWindow.Left) + 1,
height: Int(csbi.srWindow.Bottom - csbi.srWindow.Top) + 1)
#else
var w = winsize()

#if os(OpenBSD)
// TIOCGWINSZ is a complex macro, so we need the flattened value.
let tiocgwinsz = Int32(0x40087468)
Expand All @@ -144,17 +190,38 @@ extension Platform {
#else
let err = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w)
#endif
let width = Int(w.ws_col)
let height = Int(w.ws_row)
guard err == 0 else { return defaultTerminalSize }
return (width: width > 0 ? width : defaultTerminalSize.width,
height: height > 0 ? height : defaultTerminalSize.height)
guard err == 0 else { return (width: nil, height: nil) }

let width = Int(w.ws_col), height = Int(w.ws_row)

return (width: width > 0 ? width : nil,
height: height > 0 ? height : nil)
#endif
}

/// Returns the current terminal size, or the default if the size is unavailable.
static func terminalSize() -> (width: Int, height: Int) {
let specifiedSize = self.userSpecifiedTerminalSize()

// Avoid needlessly calling ioctl() if a complete override is in effect
if let specifiedWidth = specifiedSize.width, let specifiedHeight = specifiedSize.height {
return (width: specifiedWidth, height: specifiedHeight)
}

// Get the size self-reported by the terminal, if available
let reportedSize = self.reportedTerminalSize()

// As it isn't required that both width and height always be specified
// together, either by the user or the terminal itself, they are
// handled separately.
return (
width: specifiedSize.width ?? reportedSize.width ?? defaultTerminalSize.width,
height: specifiedSize.height ?? reportedSize.height ?? defaultTerminalSize.height
)
}

/// The current terminal size, or the default if the width is unavailable.
static var terminalWidth: Int {
terminalSize().width
self.terminalSize().width
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the explicit self

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suffice to say I wish there was an .enableUpcomingFeature() flag called "DisallowImplicitSelf" 🤣

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+10000000

}
}

8 changes: 5 additions & 3 deletions Sources/ArgumentParserTestHelpers/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ public func AssertEqualStrings(
public func AssertHelp<T: ParsableArguments>(
_ visibility: ArgumentVisibility,
for _: T.Type,
columns: Int? = 80,
equals expected: String,
file: StaticString = #file,
line: UInt = #line
Expand All @@ -229,18 +230,19 @@ public func AssertHelp<T: ParsableArguments>(
_ = try T.parse([flag])
XCTFail(file: file, line: line)
} catch {
let helpString = T.fullMessage(for: error)
let helpString = T.fullMessage(for: error, columns: columns)
AssertEqualStrings(actual: helpString, expected: expected, file: file, line: line)
}

let helpString = T.helpMessage(includeHidden: includeHidden, columns: nil)
let helpString = T.helpMessage(includeHidden: includeHidden, columns: columns)
AssertEqualStrings(actual: helpString, expected: expected, file: file, line: line)
}

public func AssertHelp<T: ParsableCommand, U: ParsableCommand>(
_ visibility: ArgumentVisibility,
for _: T.Type,
root _: U.Type,
columns: Int? = 80,
equals expected: String,
file: StaticString = #file,
line: UInt = #line
Expand All @@ -261,7 +263,7 @@ public func AssertHelp<T: ParsableCommand, U: ParsableCommand>(
}

let helpString = U.helpMessage(
for: T.self, includeHidden: includeHidden, columns: nil)
for: T.self, includeHidden: includeHidden, columns: columns)
AssertEqualStrings(actual: helpString, expected: expected, file: file, line: line)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ fileprivate struct Qux: ParsableArguments {
fileprivate struct Quizzo: ParsableArguments {
@Option() var name: String
@Flag() var verbose = false
let count = 0
let count: Int
init() { self.count = 0 } // silence warning about count not being decoded
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THANK YOU

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1000 this might allow us to enable "warnings as errors"!!

}

extension UnparsedValuesEndToEndTests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import XCTest
import ArgumentParserTestHelpers

final class CountLinesExampleTests: XCTestCase {
override func setUp() {
#if !os(Windows) && !os(WASI)
unsetenv("COLUMNS")
#endif
}

func testCountLines() throws {
guard #available(macOS 12, *) else { return }
let testFile = try XCTUnwrap(Bundle.module.url(forResource: "CountLinesTest", withExtension: "txt"))
Expand Down
6 changes: 6 additions & 0 deletions Tests/ArgumentParserExampleTests/MathExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import ArgumentParser
import ArgumentParserTestHelpers

final class MathExampleTests: XCTestCase {
override func setUp() {
#if !os(Windows) && !os(WASI)
unsetenv("COLUMNS")
#endif
}

func testMath_Simple() throws {
try AssertExecuteCommand(command: "math 1 2 3 4 5", expected: "15")
try AssertExecuteCommand(command: "math multiply 1 2 3 4 5", expected: "120")
Expand Down
6 changes: 6 additions & 0 deletions Tests/ArgumentParserExampleTests/RepeatExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import XCTest
import ArgumentParserTestHelpers

final class RepeatExampleTests: XCTestCase {
override func setUp() {
#if !os(Windows) && !os(WASI)
unsetenv("COLUMNS")
#endif
}

func testRepeat() throws {
try AssertExecuteCommand(command: "repeat hello", expected: """
hello
Expand Down
6 changes: 6 additions & 0 deletions Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import XCTest
import ArgumentParserTestHelpers

final class RollDiceExampleTests: XCTestCase {
override func setUp() {
#if !os(Windows) && !os(WASI)
unsetenv("COLUMNS")
#endif
}

func testRollDice() throws {
try AssertExecuteCommand(command: "roll --times 6")
}
Expand Down
5 changes: 5 additions & 0 deletions Tests/ArgumentParserPackageManagerTests/HelpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import XCTest
import ArgumentParserTestHelpers

final class HelpTests: XCTestCase {
override func setUp() {
#if !os(Windows) && !os(WASI)
unsetenv("COLUMNS")
#endif
}
}

func getErrorText<T: ParsableArguments>(_: T.Type, _ arguments: [String]) -> String {
Expand Down
5 changes: 5 additions & 0 deletions Tests/ArgumentParserPackageManagerTests/Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import ArgumentParser
import ArgumentParserTestHelpers

final class Tests: XCTestCase {
override func setUp() {
#if !os(Windows) && !os(WASI)
unsetenv("COLUMNS")
#endif
}
}

extension Tests {
Expand Down
52 changes: 52 additions & 0 deletions Tests/ArgumentParserUnitTests/HelpGenerationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -799,3 +799,55 @@ extension HelpGenerationTests {
""")
}
}

extension HelpGenerationTests {
private struct WideHelp: ParsableCommand {
@Argument(help: "54 characters of help, so as to wrap when columns < 80")
var argument: String?
}

func testColumnsEnvironmentOverride() throws {
#if os(Windows) || os(WASI)
throw XCTSkip("Unsupported on this platform")
#endif

defer { unsetenv("COLUMNS") }
unsetenv("COLUMNS")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to defer { unsetenv("COLUMNS") } to avoid this test colliding with other tests which assert on help screens?

I'm actually wondering if this is safely testable when swift test --parallel is used...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, that's a good point - Definitionally, it can't really be parallel-safe, in that environment variables themselves are mutable shared global state. The best I think I can do is lessen the amount of variance in the COLUMNS value (such as only setting it to 80 and 81, keeping it as close as possible to the default).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if the best solution is to just not set the env var and skip test coverage. I know thats a gross solution, but I'd much rather have 100% reliable tests than missing test coverage (imo)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a partial workaround - I can use CommandLine.arguments to detect when parallel testing is in effect (the commandline signatures are unique on both macOS and Linux), and use that to skip the test. The caveat is that it only works for swift test; I'd have to skip it unconditionally in Xcode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: I tried this solution and it worked in multiple Swift versions on both platforms, so I pushed the code.

Copy link
Copy Markdown
Member

@natecook1000 natecook1000 Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, could we have this test be the only place that checks that the default 80 column width is used, and just specify exact widths in all the other help generation tests? If we're skipping parallel tests and Xcode tests, this will only get run in Linux CI (at least by me)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yeah, the tests should probably be doing that anyway - in my default Terminal setup (in which I export COLUMNS in my ~/.bash_profile) the tests already spuriously fail even on main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@natecook1000 Okay, pushed an update that makes all the tests in the same target specify the columns explicitly (parallel testing is inconsistent about tests in other cases in the same target, but tests in other targets always run in a different runner process), and force-unsets the environment variable in the other targets.

AssertHelp(.default, for: WideHelp.self, columns: nil, equals: """
USAGE: wide-help [<argument>]

ARGUMENTS:
<argument> 54 characters of help, so as to wrap when columns < 80

OPTIONS:
-h, --help Show help information.

""")

setenv("COLUMNS", "60", 1)
AssertHelp(.default, for: WideHelp.self, columns: nil, equals: """
USAGE: wide-help [<argument>]

ARGUMENTS:
<argument> 54 characters of help, so as to
wrap when columns < 80

OPTIONS:
-h, --help Show help information.

""")

setenv("COLUMNS", "79", 1)
AssertHelp(.default, for: WideHelp.self, columns: nil, equals: """
USAGE: wide-help [<argument>]

ARGUMENTS:
<argument> 54 characters of help, so as to wrap when columns <
80

OPTIONS:
-h, --help Show help information.

""")
}
}