Skip to content

Add native response file support - #909

Open
bkhouri wants to merge 4 commits into
mainfrom
t/main/gh846_native_response_file_support
Open

bkhouri wants to merge 4 commits into
mainfrom
t/main/gh846_native_response_file_support

Conversation

@bkhouri

@bkhouri bkhouri commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

Add native response file support in Swift Argument Parser for all ParsableCommand and AsyncParsableCommand commands. When at least one @file reference is present in the input, parser error messages now include a multi-line at <file>:<line> / included from <file>:<line> block that pinpoints the offending argument and its full include chain.

Fixes: #846
Relates to #41

Checklist

  • I've added at least one test that validates that my change is working, if appropriate
  • I've followed the code style of the rest of the project
  • I've read the Contribution Guidelines
  • I've updated the documentation if necessary

Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ResponseFiles.md Outdated
Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ResponseFiles.md
Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ResponseFiles.md
Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ResponseFiles.md
@rauhul

rauhul commented May 28, 2026

Copy link
Copy Markdown
Collaborator

IMO this sort of feature will need to come with some sort of source location feature too

@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 12 times, most recently from c4a089d to 5cf5af6 Compare July 10, 2026 19:09
@bkhouri

bkhouri commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

IMO this sort of feature will need to come with some sort of source location feature too

Thanks for this feedback. A source location feature has been added.

@bkhouri
bkhouri marked this pull request as ready for review July 10, 2026 19:23
@bkhouri
bkhouri marked this pull request as draft July 10, 2026 19:24
@bkhouri

bkhouri commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

marking as Draft to prevent accidentally merging before it's ready.

Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ExperimentalFeatures.md Outdated
Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ResponseFiles.md
Comment thread Sources/ArgumentParser/Documentation.docc/Articles/ExperimentalFeatures.md Outdated
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 3 times, most recently from 368e955 to cd020b9 Compare July 14, 2026 13:53
@bkhouri
bkhouri marked this pull request as ready for review July 21, 2026 22:18
@bkhouri

bkhouri commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

#931 augments the source location information.

@bkhouri
bkhouri marked this pull request as draft July 21, 2026 22:36
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 3 times, most recently from f297509 to 8879fe4 Compare July 21, 2026 23:56
@allevato

Copy link
Copy Markdown
Contributor

Apologies if I missed it, but can you add a test for quoted empty string behavior in response files?

For example, these files:

-foo
''
-bar

and

-foo
""
-bar

should both expand to three arguments: ["-foo", "", "-bar"].

This was on my mind because a few months ago I fixed a bug in LLVM's response file parser that was incorrectly dropping empty arguments (instead of preserving them as gcc would): llvm/llvm-project#187566

In fact, some of the other test cases I added might also serve as good tests of quotation handling for this implementation, too.

@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 3 times, most recently from 5dff61a to 2065f86 Compare July 22, 2026 18:29
@bkhouri

bkhouri commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Apologies if I missed it, but can you add a test for quoted empty string behavior in response files?

Thanks for the feedback @allevato . I added more test coverage around empty quoted string s, and some with a new line character (\n) in a quoted string.

@bkhouri
bkhouri marked this pull request as ready for review July 24, 2026 21:18
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 2 times, most recently from 0a1ae06 to 2e3f6c3 Compare August 19, 2026 19:07

@natecook1000 natecook1000 left a comment

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.

Thanks, @bkhouri! Initial feedback here – I haven't reviewed the tests yet, will do that next.

Comment thread Tests/ArgumentParserEndToEndTests/ResponseFileEndToEndTests.swift Outdated
Comment thread Sources/ArgumentParser/Parsable Types/ParsableCommand.swift Outdated
Comment thread Sources/ArgumentParser/Parsing/CommandParser.swift
Comment thread Sources/ArgumentParser/Parsing/CommandParser.swift Outdated
Comment on lines +190 to +194
// Ordering (least → greatest):
// .argumentIndex(a) < .argumentIndex(b) when a < b
// .argumentIndex(_) < .responseFile(_,_) < .defaultValue
// Two `.responseFile` values are ordered by lexicographically
// comparing their flattened chain step arrays.

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.

IIUC, the arguments in a response file are expanded "in place", such that they take the position of the @file command-line argument that includes them. Are there any issues with that in-place expansion leading to an actual ordering of arguments that's different from this ordering, where all the arguments from expanded response files are positioned after all command-line arguments?

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 don't believe there are ordering issues. Every expanded argument (CLI or response-file origin) is assigned a sequential post-expansion InputIndex in SplitArguments.swift, and all origins use .argumentIndex(SplitArguments.Index). Natural < ordering on .argumentIndex therefore reflects the true in-place expansion order — a response-file arg at post-expansion index 5 sorts between CLI arg at index 4 and CLI arg at index 6. Chain provenance for user-facing error messages lives separately in FormattingContext.responseFileChains keyed by the same post-expansion InputIndex.

There are also tests that help verify the ordering:

  • in ResponseFileExpanderTests.swift:112-238 that validate the ordering within the response files processing is as expected.
  • in ResponseFileEndToEndTests.swift, responseFileWithMixedArgsLastWinsCLI:122 parses a sample CLI [@file, --count 100], where the response file sets --count 2. The test asserts count == 100, which comes after the response file on the command line.
  • in ResponseFileEndToEndTests.swift, responseFileWithMixedArgsLastWinsResponseFile:141 is the same as the previous, but reversed the order on the command line.

I added additional automated test that will validate the CLI argument expansion is as expected.

Comment thread Sources/ArgumentParser/Parsing/ResponseFileExpander.swift Outdated
Comment thread Sources/ArgumentParser/Parsing/ResponseFileExpander.swift Outdated
Comment on lines +322 to +325
fileprivate func resolveFileURL(
_ fileName: String,
relativeTo parentURL: URL? = nil
) -> URL {

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.

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 can't recall why I didn't use the existing API.

/// On POSIX, that means it starts with `/`. On Windows, that includes
/// drive-rooted paths (`C:\...` / `C:/...`), UNC paths (`\\server\...`),
/// and drive-relative-absolute paths (starting with `\` or `/`).
fileprivate static func isAbsolutePath(_ path: String) -> Bool {

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.

Let's try hard not to rewrite our own version of this logic – is this necessary if resolveFileURL is simplified?

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.

ack.

Comment thread Sources/ArgumentParserTestHelpers/TemporaryFileFixture.swift
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 5 times, most recently from cd1ab10 to 07f2035 Compare August 21, 2026 21:07
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch 2 times, most recently from d69a318 to 2b09395 Compare September 1, 2026 02:29
Add native response file support in Swift Argument Parser for all
`ParsableCommand` and `AsyncParsableCommand` commands. When at least
one `@file` reference is present in the input, parser error messages
now include a multi-line `at <file>:<line>` / `included from
<file>:<line>` block that pinpoints the offending argument and its full
include chain.

Fixes #846
Relates to #41
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch from 2b09395 to 83c9a14 Compare October 16, 2026 14:46
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch from 83c9a14 to c11dedb Compare October 16, 2026 14:50
Response files make it easy to produce argument lists far larger than
the OS allows on the command line, which exposed two quadratic-time
bugs in the parser:

- `SplitArguments.removeAll(in:)` iterated `InputOrigin`'s underlying
  `Set` in arbitrary hash order. `remove(at:)` is only O(1) when
  positions arrive in ascending order (it scans forward from
  `firstUnused`); out-of-order removal degraded every call to an O(n)
  scan. Iterate the existing sorted `elements` accessor instead.

- `hasTerminatorBetween`, called once per option to rule out an
  intervening `--`, scanned every remaining argument even when no
  terminator existed. Since `elements` is sorted by index, stop
  scanning once the target index is reached.

Also harden `ParsedValues.update` for array values: boxing the array
in `Any` inside a dictionary kept a second live reference to the
buffer during `append`, defeating copy-on-write and forcing a full
copy per element.

Added scaling tests (2,000 vs. 20,000 arguments) for repeated options
and positional arguments, via both a response file and direct argv,
that fail if parsing time grows super-linearly. Before this change,
20,000 repeated options took over five minutes; after, the full suite
of 391 tests runs in ~1 second.
@bkhouri
bkhouri force-pushed the t/main/gh846_native_response_file_support branch from 5e79423 to 1bcde41 Compare October 20, 2026 12:38

@natecook1000 natecook1000 left a comment

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.

Thanks, @bkhouri! Some notes below – let me know if you have any questions…

Comment on lines +115 to +117
- **Nested quotes** — a double-quoted string may contain unescaped single
quotes, and a single-quoted string may contain unescaped double quotes.
Only the outer quote style needs to be escaped or switched.

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.

This bullet isn't like the other two – could it just be a short paragraph underneath the two bullets?

Comment on lines +156 to +158
Whitespace inside a quoted value (of either style) is preserved and treated
as part of the value — it does not split the argument. Outside of quotes,
runs of spaces and tabs separate arguments as usual.

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.

What about line breaks?

Comment on lines +166 to +177
##### `@file` inside a quoted argument

The response-file prefix is only interpreted when it starts an *unquoted*
token. A quoted string that happens to begin with `@` is passed through as a
literal value (this differs from the `@@`-escape mechanism described in
[Literal At Signs](#literal-at-signs), which only applies to unquoted
tokens):

```
--username "@admin" # value is "@admin"; no file is opened
--pattern '@daily' # value is "@daily"; single quotes protect it too
```

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.

This should reference the user's chosen response file prefix, rather than (or in addition to) "@" specifically.

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.

Actually, this doesn't seem worthy of its own section. Maybe a sentence in the Nested Response Files section?

Comment on lines +204 to +212
#### Equals Format

Arguments can use the equals format:

```
--input=input.txt
--output=output.txt
--count=42
```

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.

Let's just include this "equals" format in the examples above.

Error: Maximum nesting depth (32) exceeded for response files
```

### Examples

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.

I think we can scrap all of these examples.

Comment on lines +25 to +28
//
// The "gate inactive" tests act as a regression guard — they should pass
// today and continue to pass after the feature lands. The "gate active"
// tests are expected to FAIL until the feature is implemented.

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.

Requesting these kinds of comments — about process rather than documenting what the file describes — be removed.

Suggested change
//
// The "gate inactive" tests act as a regression guard — they should pass
// today and continue to pass after the feature lands. The "gate active"
// tests are expected to FAIL until the feature is implemented.

Comment on lines +1373 to +1393
// MARK: - Configuration Options

extension ResponseFileEndToEndTests {
// These tests will verify that response file support can be configured
// The actual configuration mechanism will be defined during implementation

@Test func disableResponseFileSupport() throws {
// Test that response file support can be disabled per command
// This would be implemented as a configuration option
// For now, this is a placeholder for the feature

// When disabled, @file should be treated as a literal argument
// Implementation details TBD
}

@Test func responseFileSearchPaths() throws {
// Test that response files can be searched in multiple directories
// Implementation details TBD
}
}

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.

Remove…

Comment on lines +1714 to +1763
@Test() func repeatedOptionParsingScalesLinearlyWithResponseFileSize()
async throws
{
func content(count: Int) -> String {
(0..<count).map { "--tag\nvalue\($0)\n" }.joined()
}

let small = try await responseFileParseDuration(
RepeatedOptionCommand.self, content: content(count: 2_000),
fileName: "repeated-option-small.txt")
let large = try await responseFileParseDuration(
RepeatedOptionCommand.self, content: content(count: 20_000),
fileName: "repeated-option-large.txt")

#expect(
large < small * 30,
"""
Parsing time scaled super-linearly with input size: \(small) for \
2,000 repeated options vs \(large) for 20,000. A 10x increase in \
input should cost roughly 10x the time, not ~100x, which would \
indicate an O(n^2) hot path.
"""
)
}

@Test() func positionalArgumentParsingScalesLinearlyWithResponseFileSize()
async throws
{
func content(count: Int) -> String {
(0..<count).map { "file\($0).txt\n" }.joined()
}

let small = try await responseFileParseDuration(
PositionalCommand.self, content: content(count: 2_000),
fileName: "positional-small.txt")
let large = try await responseFileParseDuration(
PositionalCommand.self, content: content(count: 20_000),
fileName: "positional-large.txt")

#expect(
large < small * 30,
"""
Parsing time scaled super-linearly with input size: \(small) for \
2,000 positional arguments vs \(large) for 20,000. A 10x increase \
in input should cost roughly 10x the time, not ~100x, which would \
indicate an O(n^2) hot path.
"""
)
}
}

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.

I think you're covering this in the ...+Performance.swift file above – let's just run these perf tests once.

}
result.append(char)
case "'":
if !inDoubleQuotes {

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.

Does \ escaping only work inside double quotes? I would have thought that

abc\"def

would include a bare non-quoting double quote: abc"def.

helpNames: NameSpecification? = nil,
aliases: [String] = []
aliases: [String] = [],
responseFilePrefix: Character? = nil

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.

I think a more specific API would be more clear about the configuration, and also do a better job of pushing people toward the convention. How about:

enum ResponseFile {
    case disabled
    case enabled(prefix: Character)

    static var enabled: {
        .enabled(prefix: "@")
    }

    var prefixCharacter: Character? { ... }
    var isEnabled: Bool { .... }
}

Then we make .disabled the default, and just passing responseFile: .enabled gives the correct "@" prefix, without people needing to look it up.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add native response file support

6 participants