Repository: pantsbuild/pants

Issue:
Integrate shell-completion with Pants 
Since #16200 has landed, integration with Pants should be the next focus (plus dedicated ZSH completions wouldn't hurt). The merged PR was strictly a Python script to generate bash completions.

The outstanding questions are:
- Which goal does this fall under?
- Is there any way to auto-generate completions when `pants.toml` is changed?
- Can the completions be auto-sourced after the first Pants run?




Discussion:
@Eric-Arellano @benjyw 

Any thoughts on the outstanding questions? I'm not really sure in which direction I can take this to integrate with Pants. 

From the Slack conversations, `export` would seem like a decent goal, but not today's version of export, which is more related to `venv`s

With all the API discussions we've had the past few weeks and my general dislike of API bloat, I'd rather this not be a new goal, but somehow be shoe-horned into something else. I can take a crack at something, but I don't yet know what that "thing" is.

Alternatively (and I'm not super into this either) I could intermezzo this with an `experimental-completions` goal with the knowledge that "experimental-" prefixed goals will be changed when they graduate. 
I'd love to play with this, but it wasn't working for me, as we discussed on Slack or on the ticket, I forget which... Has anything been fixed there? Once I get to using it regularly I can form opinions about how to proceed...
Yep, just pushed two fixes: https://github.com/pantsbuild/pants/pull/16442

Works on Mac and Debian Bullseye for me now, no warnings/complaints.
I think we should redesign `export`, possibly merging `export-codegen` into it.

If you don't want to be blocked, you could call it `experimental-shell-autocomplete`.

> Is there any way to auto-generate completions when pants.toml is changed?

This would be https://github.com/pantsbuild/pants/issues/12014
> I'm fine with a long-term alias. And it is worth rethinking the "temporary name while a feature is experimental" [habit]?

>> Maybe we should get more cozy with "experimental" in general?

> Or less...

Re: https://github.com/pantsbuild/pants/pull/16397 @benjyw Thoughts? I don't want to start creating new experimentally-namespaced goals, while discussing in another ticket whether we should have them or not.

Here's my entirely selfish perspective of my DX workflow over the last couple weeks. 

From mainline Pants, I've generated the most thorough `completion.bash` I can (`python3 build-support/bin/generate_completions.py > pants-completions.bash`), and aliased it into my user's completions folder. So, while I may not have the same tools/plugins as mainline in any given repo, I know enough that I can use my auto-complete without any problems.

This isn't the best, as it doesn't react to my `pants.toml`... But so be it, it's already miles ahead of me trying to remember something, or jumping into help to find the letters to type.

The biggest reason for completions for me was related to introspection commands, which I'm trying to solve by creating that super trivial VSCode extension, so introspection tools are always visible.

This is all to say that I'm totally fine not creating any custom goals until `export` or whatever is sorted out. Personally, I'm in no rush and any Pants maintainers/contributors can probably end up in the same boat. 

The completions might be much more useful for new/casual Pants users, who don't have all the commands memorized. For that case, we could even do something similar to what I'm doing - which is to generate a single, massive completions file, host that on the website and let users know that it might not all work, depending on their pants.toml. 

Ugly workaround? Sure - but better than touching the public API in my mind. 





I want to play with this for a couple of days (now that it should work, after your recent changes), and hopefully that will give me ideas. 
Just so this isn't fully lost to Slack history (between Stu and I):

> So what about doing something more like `--version`? ./pants --completions ?

> this would work too probably. goals don’t have access to the help APIs in general, but “BuiltinGoals” do. so a ./pants completions would be possible.

I'll take a crack at this, and see if I can get anything created by Pants for arbitrary repos, and then we can work on naming.
Thanks a lot @sureshjoshi!
@stuhood No problem, but we still need an endgame here wrt my Slack thread (https://pantsbuild.slack.com/archives/C0D7TNJHL/p1664221191673119). 

> However, this leaves me with a couple outstanding questions:
> 1. What should the completions "goal" be called? ./pants completions  - even if it's contrived, I like the idea of something like ./pants --completions which kinda indicates that it's acting on the pants instance itself, rather than being a verb for some other work
> 2. What should the output of said pseudo-goal be? Print the completions to the console? Export to dist? This is probably a per-repo file, since it depends on what the installed backend packages are - so it may not be something trivially exported to a completions folder somewhere on the user's filesystem
> 3. Can this be auto-updated if the backend-packages change?

According to Eric, 3 relates to https://github.com/pantsbuild/pants/issues/12014 


> > 2. What should the output of said pseudo-goal be? Print the completions to the console? Export to dist? This is probably a per-repo file, since it depends on what the installed backend packages are - so it may not be something trivially exported to a completions folder somewhere on the user's filesystem

This is an important question, yea. Looking around at prior art would be good here, as I don't know how tools do this in general.

But do note that one annoying issue in this case is that each repo on your system might be using a different version of pants. Any bit of automation that attempted to write out a global config would need to be version aware (and perhaps have a shim at the top level to decide which copy of the completions to use?) Or we could recommend that people use `direnv`, perhaps.

> > 3. Can this be auto-updated if the backend-packages change?
> 
> According to Eric, 3 relates to #12014

I don't think that it needs to be: because this would be a built-in goal, it would not be running as `@rule` code, and so it would not be subject to restrictions on what it could read/write to the filesystem (with the caveat that it would need to invent its own memoization if you wanted to avoid checking the filesystem on each run).

> > 1. What should the completions "goal" be called? ./pants completions  - even if it's contrived, I like the idea of something like ./pants --completions which kinda indicates that it's acting on the pants instance itself, rather than being a verb for some other work

If it ends up auto-updated, then I don't think that it would need a goal name: rather, an option name? But perhaps you'd still need a goal for the initial install of completions. It's also possible though that the [installation script](https://www.pantsbuild.org/v2.14/docs/installation) could bootstrap what is needed there.
> This is an important question, yea. Looking around at prior art would be good here, as I don't know how tools do this in general.

For, let's say, "static" tooling - it often comes down at installation time.
e.g. `/usr/local/Cellar/git/2.37.3/share/zsh/site-functions/git-completion.bash` which can be symlinked into wherever (I seem to have completions in like, 10 folders on my machine).
`/usr/local/share/zsh/site-functions`
`/usr/local/etc/bash_completion.d/`

For something a bit more dynamic, `python-fire` has a command to generate them and then you pipe those completions wherever you want them:
https://github.com/google/python-fire/blob/master/docs/using-cli.md#completion-flag

> It's also possible though that the [installation script](https://www.pantsbuild.org/v2.14/docs/installation) could bootstrap what is needed there.

My original idea in the long long ago times was that it would be part of the installation script somehow, which also might give us some ability to hook into changes to the backend packages. Felt both like a good and bad idea to be in that script, if we could enable/disable it to the user's preference.  

Whether it's a good idea or not, I was thinking that you could source the repo's completions after your first `./pants` command, and then the completions would be available and ready-sourced.

> Any bit of automation that attempted to write out a global config would need to be version aware (and perhaps have a shim at the top level to decide which copy of the completions to use?) 

This is something I didn't think too much about - I'd never seen it done (at least, I don't think I have). If we could dynamically re-direct to the current directory's `dist` from a global shim, that would be interesting.
> This is something I didn't think too much about - I'd never seen it done (at least, I don't think I have). If we could dynamically re-direct to the current directory's `dist` from a global shim, that would be interesting.

Yea... this would be a bit like a custom implementation of `direnv` I suppose?
Oh yeah, I guess so. I don't currently use `direnv` (or any shell extensions, actually). 

Do we have any idea on the ratios of users to number of Pants repos a given user works on? For me, it's about 10:1 repos per SJ, but I would think that in companies focusing on monorepos, it might be less. 

So, if there is a default single-repo path, and then "here is a tool/way/mechanism for a multi-repo case"?

For the single-repo case, I think just printing to console, and then letting the user pipe wherever they so choose could make sense. 

Hmm, though, this kinda falls over to backend-package changes... Blah.
Ok; so... coming in here like a buffoon and probably asking questions you've talked about elsewhere... Wouldn't going for fully dynamic completion be somewhat easier (from a user viewpoint, not necessarily tech)? :-) 

```bash
_pants () {
    pants="${COMP_WORDS[0]}"; 
    local words=("${COMP_WORDS[@]}")
    local completions=$($pants --complete --word $COMP_CWARD --point $COMP_POINT "${words[@]}")
    COMPREPLY=( $(compgen -W "$completions" -- "$word") )
}

complete -F _pants pants
```

Works for `./pants`, `../pants`, `foobar/pants`, and so on. Solves the "which pants repo" since it'll use the one the user is using. Avoids the whole "is it up to date" conundrum. Even if implementing it like this might turn out too complicated, one could take the "which pants" approach and use that to refresh the list on each run.
Thanks for the feedback, and yeah, we briefly talked about this yesterday on the slack channel. 

In short, it's not a bad idea to do that on `tab`, BUT, you're at the mercy of the time taken to re-run the pants process on each `tab`. 

On my machine (6-core i5 Mac Mini), I think it ended up somewhere between 0.5-2 seconds per call - with the daemon running, and that's without any real substantial changes to the code base.

If calling `./pants --complete` was essentially a no-op, it would be a great approach, but it's a bit more involved. Also, backend packages don't really change frequently, so the "is it up-to-date" should be a rare (but important) check...

In lieu of fully dynamic, as you suggested, putting something in the pants script would be the intermezzo - but I'm just not sure how that would look, with config as well. 

> On my machine (6-core i5 Mac Mini), I think it ended up somewhere between 0.5-2 seconds per call - with the daemon running, and that's without any real substantial changes to the code base.

Finishing shipping [#7369](https://github.com/pantsbuild/pants/issues/7369#issuecomment-1217432512) will unblock improving latency, because it will ease shipping the native-client that is already implemented. It will also touch the `pants` script.

That will still only be for the "warm `pantsd`" case, but an approach which can _evolve_ into dynamic would be great.
> Thanks for the feedback, and yeah, we briefly talked about this yesterday on the slack channel.
> 
> In short, it's not a bad idea to do that on tab, BUT, you're at the mercy of the time taken to re-run the pants process on each tab.
> 
> On my machine (6-core i5 Mac Mini), I think it ended up somewhere between 0.5-2 seconds per call - with the daemon running, and that's without any real substantial changes to the code base.

Figured as much, but wanted to check. Mine doesn't take that long (<0.5s) which IMO is fine, but 2s would be a bit annoying if it's every tab.

> If calling ./pants --complete was essentially a no-op, it would be a great approach, but it's a bit more involved. Also, backend packages don't really change frequently, so the "is it up-to-date" should be a rare (but important) check...
> 
> In lieu of fully dynamic, as you suggested, putting something in the pants script would be the intermezzo - but I'm just not sure how that would look, with config as well.

My primary use-case isn't goals, it's targets[^1]. Finding the right target in a directory, finding out which ones exist, etc. That's going to change much more often. I had a bit of a check in how Buck and Bazel do it; and they seem to both have options for either querying via the tool or dumb grepping for targets. Both seem to depend on some known lists of commands and flags as well; likely shipped with the binary. But since both goals and targets (and which goals affect which target) can change (plus depending on location), that is a bit more complex for Pants.

Spitballing here; could the list be maintained by the daemon? So instead of `./pants` calling the daemon, the daemon pushes a file to `.pants.d`?

[^1]: At least from my experience with Bazel, which admittedly overloads the targets much more than the verbs.
That's great information! 

This **first** version of the completions only cover the goals/options/subsystems/etc - targets are a step two, as other than filesystem targets, those internal to BUILD files are going to need to be more clever (which is another good use case for dynamically created completions!)

Resolving patch:
diff --git a/src/python/pants/goal/builtins.py b/src/python/pants/goal/builtins.py
--- a/src/python/pants/goal/builtins.py
+++ b/src/python/pants/goal/builtins.py
@@ -7,6 +7,7 @@
 from pants.build_graph.build_configuration import BuildConfiguration
 from pants.goal import help
 from pants.goal.builtin_goal import BuiltinGoal
+from pants.goal.completion import CompletionBuiltinGoal
 from pants.goal.explorer import ExplorerBuiltinGoal
 from pants.goal.migrate_call_by_name import MigrateCallByNameBuiltinGoal
 
@@ -18,6 +19,7 @@ def register_builtin_goals(build_configuration: BuildConfiguration.Builder) -> N
 def builtin_goals() -> tuple[type[BuiltinGoal], ...]:
     return (
         BSPGoal,
+        CompletionBuiltinGoal,
         ExplorerBuiltinGoal,
         MigrateCallByNameBuiltinGoal,
         help.AllHelpBuiltinGoal,
diff --git a/src/python/pants/goal/completion.py b/src/python/pants/goal/completion.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/goal/completion.py
@@ -0,0 +1,184 @@
+# Copyright 2024 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+# TODO: This was written as a port of the original bash script, but since we have
+# more knowledge of the options and goals, we can make this more robust and accurate (after tests are written).
+
+from __future__ import annotations
+
+import logging
+from enum import Enum
+
+from pants.base.exiter import PANTS_SUCCEEDED_EXIT_CODE, ExitCode
+from pants.base.specs import Specs
+from pants.build_graph.build_configuration import BuildConfiguration
+from pants.engine.unions import UnionMembership
+from pants.goal.builtin_goal import BuiltinGoal
+from pants.init.engine_initializer import GraphSession
+from pants.option.option_types import EnumOption
+from pants.option.options import Options
+from pants.option.scope import GLOBAL_SCOPE
+from pants.util.resources import read_resource
+from pants.util.strutil import softwrap
+
+_COMPLETIONS_PACKAGE = "pants.goal"
+
+logger = logging.getLogger(__name__)
+
+
+class Shell(Enum):
+    BASH = "bash"
+    ZSH = "zsh"
+
+
+class CompletionBuiltinGoal(BuiltinGoal):
+    name = "complete"
+    help = softwrap(
+        """
+        Generates a completion script for the specified shell. The script is printed to stdout.
+
+        For example, `pants complete --zsh > pants-completions.zsh` will generate a zsh
+        completion script and write it to the file `my-pants-completions.zsh`. You can then
+        source this file in your `.zshrc` file to enable completion for Pants.
+
+        This command is also used by the completion scripts to generate the completion options using
+        passthrough options. This usage is not intended for use by end users, but could be
+        useful for building custom completion scripts.
+
+        An example of this usage is in the bash completion script, where we use the following command:
+        `pants complete -- ${COMP_WORDS[@]}`. This will generate the completion options for the
+        current args, and then pass them to the bash completion script.
+        """
+    )
+
+    shell = EnumOption(
+        default=Shell.BASH,
+        help="Which shell completion type should be printed to stdout.",
+    )
+
+    def run(
+        self,
+        *,
+        build_config: BuildConfiguration,
+        graph_session: GraphSession,
+        options: Options,
+        specs: Specs,
+        union_membership: UnionMembership,
+    ) -> ExitCode:
+        """This function is called under two main circumstances.
+
+        - By a user generating a completion script for their shell (e.g. `pants complete --zsh > pants-completions.zsh`)
+        - By the shell completions script when the user attempts a tab completion (e.g. `pants <tab>`, `pants fmt lint che<tab>`, etc...)
+
+        In the first case, we should generate a completion script for the specified shell and print it to stdout.
+        In the second case, we should generate the completion options for the current command and print them to stdout.
+
+        The trigger to determine which case we're in is the presence of the passthrough arguments. If there are passthrough
+        arguments, then we're generating completion options. If there are no passthrough arguments, then we're generating
+        a completion script.
+        """
+        if options._passthru:
+            completion_options = self._generate_completion_options(options)
+            if completion_options:
+                print("\n".join(completion_options))
+            return PANTS_SUCCEEDED_EXIT_CODE
+
+        script = self._generate_completion_script(self.shell)
+        print(script)
+        return PANTS_SUCCEEDED_EXIT_CODE
+
+    def _generate_completion_script(self, shell: Shell) -> str:
+        """Generate a completion script for the specified shell.
+
+        Implementation note: In practice, we're just going to read in
+        and return the contents of the appropriate static completion script file.
+
+        :param shell: The shell to generate a completion script for.
+        :return: The completion script for the specified shell.
+        """
+        if shell == Shell.ZSH:
+            return read_resource(_COMPLETIONS_PACKAGE, "pants-completion.zsh").decode("utf-8")
+        else:
+            return read_resource(_COMPLETIONS_PACKAGE, "pants-completion.bash").decode("utf-8")
+
+    def _generate_completion_options(self, options: Options) -> list[str]:
+        """Generate the completion options for the specified args.
+
+        We're guaranteed to have at least two arguments (`["pants", ""]`). If there are only two arguments,
+        then we're at the top-level Pants command, and we should show all goals.
+        - `pants <tab>` -> `... fmt fix lint list repl run test ...`
+
+        If we're at the top-level and the user has typed a hyphen, then we should show global options.
+        - `pants -<tab>` -> `... --pants-config-files --pants-distdir --pants-ignore ...`
+
+        As we add goals, we should show the remaining goals that are available.
+        - `pants fmt fix lint <tab>` -> `... list repl run test ...`
+
+        If there is a goal in the list of arguments and the user has typed a hyphen, then we should
+        show the available scoped options for the previous goal.
+        - `pants fmt -<tab>` -> `... --only ...`
+
+        :param options: The options object for the current Pants run.
+        :return: A list of candidate completion options.
+        """
+        logger.debug(f"Completion passthrough options: {options._passthru}")
+        args = [arg for arg in options._passthru if arg != "pants"]
+        current_word = args.pop()
+        previous_goal = self._get_previous_goal(args)
+        logger.debug(f"Current word is '{current_word}', and previous goal is '{previous_goal}'")
+
+        all_goals = sorted([k for k, v in options.known_scope_to_info.items() if v.is_goal])
+
+        # If there is no previous goal, then we're at the top-level Pants command, so show all goals or global options
+        if not previous_goal:
+            if current_word.startswith("-"):
+                global_options = self._build_options_for_goal(options)
+                candidate_options = [o for o in global_options if o.startswith(current_word)]
+                return candidate_options
+
+            candidate_goals = [g for g in all_goals if g.startswith(current_word)]
+            return candidate_goals
+
+        # If there is already a previous goal and current_word starts with a hyphen, then show scoped options for that goal
+        if current_word.startswith("-"):
+            scoped_options = self._build_options_for_goal(options, previous_goal)
+            candidate_options = [o for o in scoped_options if o.startswith(current_word)]
+            return candidate_options
+
+        # If there is a previous goal and current_word does not start with a hyphen, then show remaining goals
+        # excluding the goals that are already in the command
+        candidate_goals = [
+            g for g in all_goals if g.startswith(current_word) and g not in options._passthru
+        ]
+        return candidate_goals
+
+    def _get_previous_goal(self, args: list[str]) -> str | None:
+        """Get the most recent goal in the command arguments, so options can be correctly applied.
+
+        A "goal" in the context of completions is simply an arg where the first character is alphanumeric.
+        This under-specifies the goal, because detecting whether an arg is an "actual" goal happens elsewhere.
+
+        :param args: The list of arguments to search for the previous goal.
+        :return: The previous goal, or None if there is no previous goal.
+        """
+        return next((arg for arg in reversed(args) if arg[:1].isalnum()), None)
+
+    def _build_options_for_goal(self, options: Options, goal: str = "") -> list[str]:
+        """Build a list of stringified options for the specified goal, prefixed by `--`.
+
+        :param options: The options object for the current Pants run.
+        :param goal: The goal to build options for. Defaults to "" for the global scope.
+        :return: A list of options for the specified goal.
+        """
+
+        if goal == GLOBAL_SCOPE:
+            global_options = sorted(options.for_global_scope().as_dict().keys())
+            return [f"--{o}" for o in global_options]
+
+        try:
+            scoped_options = sorted(options.for_scope(goal).as_dict().keys())
+            return [f"--{o}" for o in scoped_options]
+        except Exception:
+            # options.for_scope will throw if the goal is unknown, so we'll just return an empty list
+            # Since this is used for user-entered tab completion, it's not a warning or error
+            return []
diff --git a/src/python/pants/option/options.py b/src/python/pants/option/options.py
--- a/src/python/pants/option/options.py
+++ b/src/python/pants/option/options.py
@@ -274,7 +274,12 @@ def register(*args, **kwargs):
         return register
 
     def get_parser(self, scope: str) -> Parser:
-        """Returns the parser for the given scope, so code can register on it directly."""
+        """Returns the parser for the given scope, so code can register on it directly.
+
+        :param scope: The scope to retrieve the parser for.
+        :return: The parser for the given scope.
+        :raises pants.option.errors.ConfigValidationError: if the scope is not known.
+        """
         try:
             return self._parser_by_scope[scope]
         except KeyError:
@@ -350,6 +355,10 @@ def for_scope(
         Computed lazily per scope.
 
         :API: public
+        :param scope: The scope to get options for.
+        :param check_deprecations: Whether to check for any deprecations conditions.
+        :return: An OptionValueContainer representing the option values for the given scope.
+        :raises pants.option.errors.ConfigValidationError: if the scope is unknown.
         """
 
         values_builder = OptionValueContainerBuilder()


Test patch:
diff --git a/src/python/pants/goal/completion_integration_test.py b/src/python/pants/goal/completion_integration_test.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/goal/completion_integration_test.py
@@ -0,0 +1,92 @@
+# Copyright 2024 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from pants.goal.completion import CompletionBuiltinGoal
+from pants.option.option_value_container import OptionValueContainer
+from pants.testutil.pants_integration_test import PantsResult, run_pants
+
+
+def test_get_previous_goal():
+    helper = CompletionBuiltinGoal(OptionValueContainer({}))
+    assert helper._get_previous_goal([""]) is None
+    assert helper._get_previous_goal(["--keep_sandboxes=always"]) is None
+    assert helper._get_previous_goal(["--keep_sandboxes=always", "fmt"]) == "fmt"
+    assert helper._get_previous_goal(["--keep_sandboxes=always", "fmt", "--only"]) == "fmt"
+    assert helper._get_previous_goal(["--keep_sandboxes=always", "fmt", "--only", "lint"]) == "lint"
+    assert (
+        helper._get_previous_goal(["--keep_sandboxes=always", "fmt", "--only", "lint", "::"])
+        == "lint"
+    )
+
+
+def run_pants_complete(args: list[str]) -> PantsResult:
+    """While there may be a way to test tab-completion directly in pytest, that feels too clever.
+
+    We can equivalently test the command that the tab-completion will call.
+
+    Note: The "pants" after "--" is intentional, as that's how the completion script would call it.
+    """
+    return run_pants(["pants", "complete", "--", "pants", *args])
+
+
+def test_completion_script_generation():
+    default_result = run_pants(["complete"])
+    default_result.assert_success()
+
+    bash_result = run_pants(["complete", "--shell=bash"])
+    bash_result.assert_success()
+    assert "COMPREPLY" in bash_result.stdout
+
+    zsh_result = run_pants(["complete", "--shell=zsh"])
+    zsh_result.assert_success()
+    assert "compdef" in zsh_result.stdout
+
+    other_result = run_pants(["complete", "--shell=gibberish"])
+    other_result.assert_failure()
+
+
+def test_completions_with_global_options():
+    result = run_pants_complete(["-"])
+    result.assert_success()
+    lines = result.stdout.splitlines()
+    assert all(line.startswith("--") for line in lines)
+    assert all(o in lines for o in ("--backend_packages", "--colors", "--loop"))  # Spot check
+
+
+def test_completions_with_all_goals():
+    result = run_pants_complete([""])
+    result.assert_success()
+    lines = result.stdout.splitlines()
+    assert all(not line.startswith("-") for line in lines)
+    assert all(o in lines for o in ("check", "help", "version"))  # Spot check
+
+
+def test_completions_with_all_goals_excluding_previous_goals():
+    result = run_pants_complete(["check", ""])
+    result.assert_success()
+    lines = result.stdout.splitlines()
+    assert all(not line.startswith("-") for line in lines)
+    assert all(o in lines for o in ("help", "version"))  # Spot check
+    assert "check" not in lines
+
+    # Check that multiple goals are excluded
+    result = run_pants_complete(["check", "help", ""])
+    result.assert_success()
+    lines = result.stdout.splitlines()
+    assert all(not line.startswith("-") for line in lines)
+    assert "version" in lines  # Spot check
+    assert all(o not in lines for o in ("check", "help"))
+
+
+def test_completions_with_goal_options():
+    result = run_pants_complete(["fmt", "-"])
+    result.assert_success()
+    lines = result.stdout.splitlines()
+    assert all(line.startswith("--") for line in lines)
+    assert all(o in lines for o in ("--batch_size", "--only"))  # Spot check
+
+
+def test_completions_with_options_on_invalid_goal():
+    result = run_pants_complete(["unknown-goal", "-"])
+    result.assert_success()
+    assert result.stdout == ""