diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index bbfaf86a..9e02138e 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -121,13 +121,14 @@ graph TD | 走査 | `_create_parser()` の木を入れ子の parser まで辿る。別名のグループ(`ct`)は `cli.GROUP_ALIASES` で正式の名前に直して数える | | `[name]` の集合 | 位置引数 `name` を持つ parser のうち、トップレベルと `project` グループ(入れ子の `profile` を含む)の道。`plugin` / `snapshot` / `env backend use` の `name` は含めない | | `--context` の集合 | `--context` を持つすべての parser の道に、トップレベルの `build`(ラッパーだけが受け付ける)を名指しで足したもの | +| `--context` を取らない末端の道 | 子の parser を持たない parser の道から `--context` の集合を引いたもの。補完の `--context` の余分を比べる道で、親の道(`project profile` など)は含めない。親の道の zsh の分岐は子の分岐を含み、子の `--context` を拾うため | | 走査の健全性 | どちらの集合も空でなく、入れ子の道(3 語)を含み、`[name]` の集合が `plugin` / `snapshot` / `env` の道を含まない | | 写し | 比べ方 | | --- | --- | | `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` | `project` の直下で `[name]` を取る parser の名前の集合に等しい | | `bin/devbase` の `_NAME_RESOLVABLE_SHORTCUTS` | `cli.SHORTCUTS` のキーと `build` の和に等しい。あわせて、トップレベルで `[name]` を取る parser がすべて `cli.SHORTCUTS` にあること | -| `etc/devbase-completion.bash` | 一時ディレクトリの `projects/` を `DEVBASE_ROOT` にして補完を実行し、`[name]` の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。`--context` の集合のすべての道(`container` の道は `ct` で始めても)で `devbase <道> -` の候補に `--context` が出る。`[name]` の集合から補完の例外を引いた道では、`devbase <道> web -` の候補にも `--context` が出る。`project` / `container` の直下で `[name]` か `--context` を取るサブコマンド(入れ子は親の `profile` として数える)が、そのグループのサブコマンドの候補に含まれる | +| `etc/devbase-completion.bash` | 一時ディレクトリの `projects/` を `DEVBASE_ROOT` にして補完を実行し、`[name]` の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。`--context` の集合のすべての道(`container` の道は `ct` で始めても)で `devbase <道> -` の候補に `--context` が出る。`[name]` の集合から補完の例外を引いた道では、`devbase <道> web -` の候補にも `--context` が出る。`--context` を取らない末端の道では、`devbase <道> -` の候補に `--context` が出ない。`project` / `container` の直下で `[name]` か `--context` を取るサブコマンド(入れ子は親の `profile` として数える)が、そのグループのサブコマンドの候補に含まれる | | `etc/_devbase` | zsh を起動せず内容を読む。`case "$words[2]"` から字下げの深さで道の分岐(`up\|down)` のような `\|` 区切りの見出し)を辿り、`[name]` の集合から補完の例外を引いた道の本体に `_devbase_project_names` がある。`--context` の集合のすべての道の本体に、値の補完を持たない `'--context[...]:context:'`(`:context:` の後ろに補完の関数を置かない形)がある。`project_subcommands` / `container_subcommands` の一覧に bash と同じサブコマンドが含まれる | | この節の 2 つの表 | 見出し `` #### `[name]` を受け付けるサブコマンド `` と `` #### `--context` を受け付けるサブコマンド `` の直後の表を読む。1 列目は `devbase <グループ...> ` の形で、間の語が道の頭になる。2 列目にバッククォートで並べた語を `` に入れる。表は `[name]` の集合・`--context` の集合にそれぞれ等しい | @@ -139,13 +140,14 @@ graph TD `tests/cli/test_completion.py` が `--context` の直後の候補が 0 件であることを代表の道で確かめる。 一致テストは zsh の補完を内容で読む。zsh が実際に出す候補そのものは、補完の実行テストが見る -([zsh の補完の実行テスト](#zsh-の補完の実行テスト))。 +([zsh の補完の実行テスト](#zsh-の補完の実行テスト))。zsh の `--context` の余分(取らない道で出す)は、 +分岐の文字列でなく実行テストの候補で比べる(X1)。 #### 補完が `--context` を出す位置 | 位置 | 例 | bash | zsh | | --- | --- | --- | --- | -| (a) 道の直後 | `devbase up -`・`devbase env exec -`・`devbase ct profile up -` | `--context` の集合のすべての道で出す | `--context` の集合のすべての道の分岐で出す | +| (a) 道の直後 | `devbase up -`・`devbase env exec -`・`devbase ct profile up -` | `--context` の集合のすべての道で出し、`--context` を取らない末端の道では出さない | 同じ(`--context` の集合のすべての道の分岐で出し、取らない末端の道では出さない) | | (b) プロジェクト名の直後 | `devbase up web -`・`devbase project open web -` | `[name]` の集合から補完の例外を引いた道で出す | `_arguments` が位置を問わず出す | | (c) `--context` の直後(値) | `devbase up --context ` | 候補は 0 件 | 説明だけを出し、候補は 0 件 | | (a)・(b) より後ろ | `devbase scale web 3 -`・`devbase env exec -- ls -` | 出すことを求めない | 出し得る(argparse では、`env exec` のコマンドの後の `--context` はそのコマンドへ渡る) | @@ -179,6 +181,7 @@ zsh(`etc/_devbase`)は、サブコマンドの分岐から `_arguments` を | I3 | `--context` の集合のすべての道(`container` の道は `ct` で始めても)の `devbase <道> -` | 候補に `--context` が入る | | I4 | `[name]` の集合から補完の例外を引いた道のうち `--context` の集合にもある道の `devbase <道> proj-a -` | 候補に `--context` が入る | | I5 | `--context` の集合のすべての道(`ct` で始めても)の `devbase <道> --context ` | 候補が 0 件 | +| X1 | `--context` を取らない末端の道の `devbase <道> -` | 候補に `--context` が入らない | | 項目 | 規則 | | --- | --- | diff --git a/tests/cli/test_name_context_consistency.py b/tests/cli/test_name_context_consistency.py index 1da729c5..8f56c7ec 100644 --- a/tests/cli/test_name_context_consistency.py +++ b/tests/cli/test_name_context_consistency.py @@ -6,7 +6,9 @@ - `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` / `_NAME_RESOLVABLE_SHORTCUTS` - 補完 2 ファイル(`etc/devbase-completion.bash` は実行して、`etc/_devbase` は静的に読む)。 - `[name]` の位置のプロジェクト名と、`--context` の旗(値は補完しない)を比べる + `[name]` の位置のプロジェクト名と、`--context` の旗(値は補完しない)を比べる。`--context` の + 余分(取らない道で勧める)は、子の parser を持たない末端の道に限って比べる。zsh の余分は + 実際の候補で比べる(test_zsh_completion_run.py) - 確定仕様 `docs/specifications/cli-argument-resolution.md` の 2 つの表 集合の要素は「サブコマンドへの道筋」のタプルで表す(例: `("project", "profile", "up")`、 @@ -95,6 +97,17 @@ def _scan(): NAME_PATHS, CONTEXT_PATHS = _scan() +def _scan_leaves(): + """子の parser を持たない末端の道(`ct` は畳む)。""" + return {_canonical(path) for path, parser in _walk(cli._create_parser()) + if path and not any(isinstance(a, argparse._SubParsersAction) for a in parser._actions)} + + +# 補完が `--context` を出してはならない道。余分を比べるのは末端の道に限る。親の道(`project +# profile` など)は、補完の本体が子の分岐を含み、子の `--context` を拾って誤って落ちるため外す。 +NO_CONTEXT_LEAF_PATHS = _scan_leaves() - CONTEXT_PATHS + + def _diff_message(where, expected, actual): missing = sorted(expected - actual) extra = sorted(actual - expected) @@ -111,6 +124,9 @@ def test_scan_finds_both_sets(): assert any(len(p) == 3 for p in CONTEXT_PATHS) # プロジェクト名でない `name`(plugin / snapshot / env backend use)を含めないこと assert not any(p[0] in ("plugin", "snapshot", "env") for p in NAME_PATHS) + # 余分を比べる末端の道を拾えていること(`env set` は `--context` を取らない末端の道) + assert ("env", "set") in NO_CONTEXT_LEAF_PATHS + assert not NO_CONTEXT_LEAF_PATHS & CONTEXT_PATHS # --------------------------------------------------------------------------- @@ -252,6 +268,14 @@ def test_bash_completion_offers_context_after_name(fake_root): f"{missing}") +def test_bash_completion_has_no_extra_context(fake_root): + # `--context` を取らない末端の道で補完が `--context` を勧めると、argparse が退ける + extra = [path for path in sorted(NO_CONTEXT_LEAF_PATHS) + if "--context" in _bash_complete(path, fake_root, "-")] + assert not extra, (f"etc/devbase-completion.bash が `--context` を取らない道の `devbase <道> -` で " + f"--context を補完する(余分): {extra}") + + # 値の補完を持たない `--context` の指定(`:context:` の後ろに補完の関数を置かない) _ZSH_CONTEXT_SPEC = re.compile(r"'--context\[[^\]']*\]:context:'") diff --git a/tests/cli/test_zsh_completion_run.py b/tests/cli/test_zsh_completion_run.py index b61299ad..5bda401a 100644 --- a/tests/cli/test_zsh_completion_run.py +++ b/tests/cli/test_zsh_completion_run.py @@ -29,6 +29,7 @@ COMPLETION_NAME_EXCEPTIONS, CONTEXT_PATHS, NAME_PATHS, + NO_CONTEXT_LEAF_PATHS, ) pytestmark = pytest.mark.xdist_group("zsh_completion_run") @@ -43,16 +44,26 @@ @dataclass(frozen=True) class Request: - invariant: str # どの不変条件の行か(I1〜I5) + invariant: str # どの不変条件の行か(I1〜I5、余分は X1) line: str # 打つ行(この後で Tab を押す) include: frozenset[str] # 候補に含まれるべき語。empty が真なら使わない empty: bool = False # 候補が 0 件であるべき + exclude: frozenset[str] = frozenset() # 候補に含まれてはならない語 def expected(self) -> str: - return "0 件" if self.empty else f"{', '.join(sorted(self.include))} を含む" + if self.empty: + return "0 件" + parts = [] + if self.include: + parts.append(f"{', '.join(sorted(self.include))} を含む") + if self.exclude: + parts.append(f"{', '.join(sorted(self.exclude))} を含まない") + return "・".join(parts) def met_by(self, candidates: frozenset[str]) -> bool: - return not candidates if self.empty else self.include <= candidates + if self.empty: + return not candidates + return self.include <= candidates and not self.exclude & candidates def _with_alias(paths): @@ -74,6 +85,9 @@ def _requests() -> list[Request]: for p in sorted(named & CONTEXT_PATHS)] reqs += [Request("I5", _line(p, "--context", ""), frozenset(), empty=True) for p in _with_alias(CONTEXT_PATHS)] + # 余分: `--context` を取らない末端の道の後の `-` で `--context` を勧めない(#453) + reqs += [Request("X1", _line(p, "-"), frozenset(), exclude=frozenset({"--context"})) + for p in sorted(NO_CONTEXT_LEAF_PATHS)] return reqs @@ -165,13 +179,19 @@ def test_context_value_has_no_candidates(completion_run): _check(completion_run, "I5") +def test_no_extra_context_on_leaf_without_it(completion_run): + """X1: `--context` を取らない末端の道の後の `-` で `--context` を出さない。""" + _check(completion_run, "X1") + + def test_requests_cover_acceptance_lines(): # 要求の受け入れ条件 2〜5 の代表の行が一覧に入っていること(走査の変化で黙って抜けない) lines = {(r.invariant, r.line) for r in REQUESTS} for item in [("I1", "devbase up "), ("I1", "devbase project up "), ("I2", "devbase login "), ("I3", "devbase up -"), ("I3", "devbase env exec -"), ("I3", "devbase ct profile up -"), ("I3", "devbase project post-start -"), ("I4", "devbase up proj-a -"), - ("I5", "devbase up --context "), ("I5", "devbase env exec --context ")]: + ("I5", "devbase up --context "), ("I5", "devbase env exec --context "), + ("X1", "devbase env set -")]: assert item in lines, f"打つ行の一覧に {item} が無い"