Skip to content

Commit c10af98

Browse files
authored
Respect the COLUMNS and LINES environment variables when present (#596)
* Respect the `COLUMNS` and `LINES` environment variables, if set, when determining screen size. * Add test for COLUMNS environment override * Make columns test idempotent against there being a COLUMNS value already set in the environment * Make help tests be more explicit about screen widths.
1 parent 75dae3d commit c10af98

File tree

12 files changed

+197
-24
lines changed

12 files changed

+197
-24
lines changed

Sources/ArgumentParser/Parsable Types/ParsableArguments.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,23 @@ extension ParsableArguments {
112112
MessageInfo(error: error, type: self).fullText(for: self)
113113
}
114114

115+
/// Returns a full message for the given error, including usage information,
116+
/// if appropriate.
117+
///
118+
/// - Parameters:
119+
/// - error: An error to generate a message for.
120+
/// - columns: The column width to use when wrapping long line in the
121+
/// help screen. If `columns` is `nil`, uses the current terminal
122+
/// width, or a default value of `80` if the terminal width is not
123+
/// available.
124+
/// - Returns: A message that can be displayed to the user.
125+
public static func fullMessage(
126+
for error: Error,
127+
columns: Int?
128+
) -> String {
129+
MessageInfo(error: error, type: self, columns: columns).fullText(for: self)
130+
}
131+
115132
/// Returns the text of the help screen for this type.
116133
///
117134
/// - Parameters:

Sources/ArgumentParser/Usage/MessageInfo.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ enum MessageInfo {
1717
case validation(message: String, usage: String, help: String)
1818
case other(message: String, exitCode: ExitCode)
1919

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

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

3535
case .dumpHelpRequested:
@@ -64,7 +64,7 @@ enum MessageInfo {
6464

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

7070
default:
@@ -97,7 +97,7 @@ enum MessageInfo {
9797
if let command = command {
9898
commandStack = CommandParser(type.asCommand).commandStack(for: command)
9999
}
100-
self = .help(text: HelpGenerator(commandStack: commandStack, visibility: .default).rendered())
100+
self = .help(text: HelpGenerator(commandStack: commandStack, visibility: .default).rendered(screenWidth: columns))
101101
case .dumpRequest(let command):
102102
if let command = command {
103103
commandStack = CommandParser(type.asCommand).commandStack(for: command)
@@ -120,7 +120,7 @@ enum MessageInfo {
120120
} else if let parserError = parserError {
121121
let usage: String = {
122122
guard case ParserError.noArguments = parserError else { return usage }
123-
return "\n" + HelpGenerator(commandStack: [type.asCommand], visibility: .default).rendered()
123+
return "\n" + HelpGenerator(commandStack: [type.asCommand], visibility: .default).rendered(screenWidth: columns)
124124
}()
125125
let argumentSet = ArgumentSet(commandStack.last!, visibility: .default, parent: nil)
126126
let message = argumentSet.errorDescription(error: parserError) ?? ""

Sources/ArgumentParser/Utilities/Platform.swift

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,25 +116,71 @@ func ioctl(_ a: Int32, _ b: Int32, _ p: UnsafeMutableRawPointer) -> Int32 {
116116

117117
extension Platform {
118118
/// The default terminal size.
119-
static var defaultTerminalSize: (width: Int, height: Int) {
120-
(80, 25)
119+
private static var defaultTerminalSize: (width: Int, height: Int) {
120+
(width: 80, height: 25)
121121
}
122122

123-
/// Returns the current terminal size, or the default if the size is
124-
/// unavailable.
125-
static func terminalSize() -> (width: Int, height: Int) {
123+
/// The terminal size specified by the COLUMNS and LINES overrides
124+
/// (if present).
125+
///
126+
/// Per the [Linux environ(7) manpage][linenv]:
127+
///
128+
/// ```
129+
/// * COLUMNS and LINES tell applications about the window size,
130+
/// possibly overriding the actual size.
131+
/// ```
132+
///
133+
/// And the [FreeBSD environ(7) version][bsdenv]:
134+
///
135+
/// ```
136+
/// COLUMNS The user's preferred width in column positions for the
137+
/// terminal. Utilities such as ls(1) and who(1) use this
138+
/// to format output into columns. If unset or empty,
139+
/// utilities will use an ioctl(2) call to ask the termi-
140+
/// nal driver for the width.
141+
/// ```
142+
///
143+
/// > Note: Always returns `(nil, nil)` on Windows and WASI.
144+
///
145+
/// - Returns: A tuple consisting of a width found in the `COLUMNS` environment
146+
/// variable (or `nil` if the variable is not present) and a height found in
147+
/// the `LINES` environment variable (or `nil` if that variable is not present).
148+
///
149+
/// [linenv]: https://man7.org/linux/man-pages/man7/environ.7.html:~:text=COLUMNS
150+
/// [bsdenv]: https://man.freebsd.org/cgi/man.cgi?environ(7)#:~:text=COLUMNS
151+
private static func userSpecifiedTerminalSize() -> (width: Int?, height: Int?) {
152+
var width: Int? = nil, height: Int? = nil
153+
154+
#if !os(Windows) && !os(WASI)
155+
if let colsCStr = getenv("COLUMNS"), let colsVal = Int(String(cString: colsCStr)) {
156+
width = colsVal
157+
}
158+
if let linesCStr = getenv("LINES"), let linesVal = Int(String(cString: linesCStr)) {
159+
height = linesVal
160+
}
161+
#endif
162+
163+
return (width: width, height: height)
164+
}
165+
166+
/// The current terminal size as reported by the windowing system,
167+
/// if available.
168+
///
169+
/// Returns (nil, nil) if no reported size is available.
170+
private static func reportedTerminalSize() -> (width: Int?, height: Int?) {
126171
#if os(WASI)
127172
// WASI doesn't yet support terminal size
128-
return defaultTerminalSize
173+
return (width: nil, height: nil)
129174
#elseif os(Windows)
130-
var csbi: CONSOLE_SCREEN_BUFFER_INFO = CONSOLE_SCREEN_BUFFER_INFO()
175+
var csbi = CONSOLE_SCREEN_BUFFER_INFO()
131176
guard GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi) else {
132-
return defaultTerminalSize
177+
return (width: nil, height: nil)
133178
}
134179
return (width: Int(csbi.srWindow.Right - csbi.srWindow.Left) + 1,
135180
height: Int(csbi.srWindow.Bottom - csbi.srWindow.Top) + 1)
136181
#else
137182
var w = winsize()
183+
138184
#if os(OpenBSD)
139185
// TIOCGWINSZ is a complex macro, so we need the flattened value.
140186
let tiocgwinsz = Int32(0x40087468)
@@ -144,17 +190,38 @@ extension Platform {
144190
#else
145191
let err = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w)
146192
#endif
147-
let width = Int(w.ws_col)
148-
let height = Int(w.ws_row)
149-
guard err == 0 else { return defaultTerminalSize }
150-
return (width: width > 0 ? width : defaultTerminalSize.width,
151-
height: height > 0 ? height : defaultTerminalSize.height)
193+
guard err == 0 else { return (width: nil, height: nil) }
194+
195+
let width = Int(w.ws_col), height = Int(w.ws_row)
196+
197+
return (width: width > 0 ? width : nil,
198+
height: height > 0 ? height : nil)
152199
#endif
153200
}
154201

202+
/// Returns the current terminal size, or the default if the size is unavailable.
203+
static func terminalSize() -> (width: Int, height: Int) {
204+
let specifiedSize = self.userSpecifiedTerminalSize()
205+
206+
// Avoid needlessly calling ioctl() if a complete override is in effect
207+
if let specifiedWidth = specifiedSize.width, let specifiedHeight = specifiedSize.height {
208+
return (width: specifiedWidth, height: specifiedHeight)
209+
}
210+
211+
// Get the size self-reported by the terminal, if available
212+
let reportedSize = self.reportedTerminalSize()
213+
214+
// As it isn't required that both width and height always be specified
215+
// together, either by the user or the terminal itself, they are
216+
// handled separately.
217+
return (
218+
width: specifiedSize.width ?? reportedSize.width ?? defaultTerminalSize.width,
219+
height: specifiedSize.height ?? reportedSize.height ?? defaultTerminalSize.height
220+
)
221+
}
222+
155223
/// The current terminal size, or the default if the width is unavailable.
156224
static var terminalWidth: Int {
157-
terminalSize().width
225+
self.terminalSize().width
158226
}
159227
}
160-

Sources/ArgumentParserTestHelpers/TestHelpers.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ public func AssertEqualStrings(
203203
public func AssertHelp<T: ParsableArguments>(
204204
_ visibility: ArgumentVisibility,
205205
for _: T.Type,
206+
columns: Int? = 80,
206207
equals expected: String,
207208
file: StaticString = #file,
208209
line: UInt = #line
@@ -229,18 +230,19 @@ public func AssertHelp<T: ParsableArguments>(
229230
_ = try T.parse([flag])
230231
XCTFail(file: file, line: line)
231232
} catch {
232-
let helpString = T.fullMessage(for: error)
233+
let helpString = T.fullMessage(for: error, columns: columns)
233234
AssertEqualStrings(actual: helpString, expected: expected, file: file, line: line)
234235
}
235236

236-
let helpString = T.helpMessage(includeHidden: includeHidden, columns: nil)
237+
let helpString = T.helpMessage(includeHidden: includeHidden, columns: columns)
237238
AssertEqualStrings(actual: helpString, expected: expected, file: file, line: line)
238239
}
239240

240241
public func AssertHelp<T: ParsableCommand, U: ParsableCommand>(
241242
_ visibility: ArgumentVisibility,
242243
for _: T.Type,
243244
root _: U.Type,
245+
columns: Int? = 80,
244246
equals expected: String,
245247
file: StaticString = #file,
246248
line: UInt = #line
@@ -261,7 +263,7 @@ public func AssertHelp<T: ParsableCommand, U: ParsableCommand>(
261263
}
262264

263265
let helpString = U.helpMessage(
264-
for: T.self, includeHidden: includeHidden, columns: nil)
266+
for: T.self, includeHidden: includeHidden, columns: columns)
265267
AssertEqualStrings(actual: helpString, expected: expected, file: file, line: line)
266268
}
267269

Tests/ArgumentParserEndToEndTests/UnparsedValuesEndToEndTest.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ fileprivate struct Qux: ParsableArguments {
2626
fileprivate struct Quizzo: ParsableArguments {
2727
@Option() var name: String
2828
@Flag() var verbose = false
29-
let count = 0
29+
let count: Int
30+
init() { self.count = 0 } // silence warning about count not being decoded
3031
}
3132

3233
extension UnparsedValuesEndToEndTests {

Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import XCTest
1515
import ArgumentParserTestHelpers
1616

1717
final class CountLinesExampleTests: XCTestCase {
18+
override func setUp() {
19+
#if !os(Windows) && !os(WASI)
20+
unsetenv("COLUMNS")
21+
#endif
22+
}
23+
1824
func testCountLines() throws {
1925
guard #available(macOS 12, *) else { return }
2026
let testFile = try XCTUnwrap(Bundle.module.url(forResource: "CountLinesTest", withExtension: "txt"))

Tests/ArgumentParserExampleTests/MathExampleTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import ArgumentParser
1414
import ArgumentParserTestHelpers
1515

1616
final class MathExampleTests: XCTestCase {
17+
override func setUp() {
18+
#if !os(Windows) && !os(WASI)
19+
unsetenv("COLUMNS")
20+
#endif
21+
}
22+
1723
func testMath_Simple() throws {
1824
try AssertExecuteCommand(command: "math 1 2 3 4 5", expected: "15")
1925
try AssertExecuteCommand(command: "math multiply 1 2 3 4 5", expected: "120")

Tests/ArgumentParserExampleTests/RepeatExampleTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import XCTest
1313
import ArgumentParserTestHelpers
1414

1515
final class RepeatExampleTests: XCTestCase {
16+
override func setUp() {
17+
#if !os(Windows) && !os(WASI)
18+
unsetenv("COLUMNS")
19+
#endif
20+
}
21+
1622
func testRepeat() throws {
1723
try AssertExecuteCommand(command: "repeat hello", expected: """
1824
hello

Tests/ArgumentParserExampleTests/RollDiceExampleTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import XCTest
1313
import ArgumentParserTestHelpers
1414

1515
final class RollDiceExampleTests: XCTestCase {
16+
override func setUp() {
17+
#if !os(Windows) && !os(WASI)
18+
unsetenv("COLUMNS")
19+
#endif
20+
}
21+
1622
func testRollDice() throws {
1723
try AssertExecuteCommand(command: "roll --times 6")
1824
}

Tests/ArgumentParserPackageManagerTests/HelpTests.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import XCTest
1414
import ArgumentParserTestHelpers
1515

1616
final class HelpTests: XCTestCase {
17+
override func setUp() {
18+
#if !os(Windows) && !os(WASI)
19+
unsetenv("COLUMNS")
20+
#endif
21+
}
1722
}
1823

1924
func getErrorText<T: ParsableArguments>(_: T.Type, _ arguments: [String]) -> String {

0 commit comments

Comments
 (0)