diff --git a/docs/completion.rst b/docs/completion.rst index 1270f23..c141c81 100644 --- a/docs/completion.rst +++ b/docs/completion.rst @@ -14,10 +14,11 @@ your shell: nnote --install-completion -This does **not** modify any shell config file. The script is placed in: +This does **not** modify any shell config file. The script is placed in an +appropriate completion directory for your shell: - **bash** — ``~/.local/share/bash-completion/completions/nnote`` -- **zsh** — ``~/.local/share/zsh/site-functions/_nnote`` +- **zsh** — the first user-writable directory already in your ``$fpath`` - **fish** — ``~/.config/fish/completions/nnote.fish`` To inspect the script before installing, use: diff --git a/nnote/completions.py b/nnote/completions.py index 6d7a104..fd0c23e 100644 --- a/nnote/completions.py +++ b/nnote/completions.py @@ -46,17 +46,43 @@ def show_completion_callback(ctx, param, value): ctx.exit() +def _zsh_install_path(): + import subprocess + + result = subprocess.run( + ["zsh", "-i", "-c", "print -l $fpath"], + capture_output=True, + text=True, + ) + home = Path.home() + for line in result.stdout.splitlines(): + p = Path(line.strip()) + if not p.is_absolute(): + continue + try: + p.relative_to(home) + except ValueError: + continue + if p.exists() and p.is_dir(): + return p / "_nnote" + return _COMPLETION_FILE["zsh"].expanduser() + + def install_completion_callback(ctx, param, value): if not value or ctx.resilient_parsing: return shell = _detect_shell() - script_file = _COMPLETION_FILE[shell].expanduser() + if shell == "zsh": + script_file = _zsh_install_path() + else: + script_file = _COMPLETION_FILE[shell].expanduser() if script_file.exists(): click.echo(f"Completion already installed in {script_file}") ctx.exit() script_file.parent.mkdir(parents=True, exist_ok=True) script_file.write_text(_generate_script(shell, ctx.command)) click.echo(f"Completion installed in {script_file}") + click.echo("Restart your shell for the change to take effect.") ctx.exit() diff --git a/tests/test_completions.py b/tests/test_completions.py index 53861e2..6fc34e3 100644 --- a/tests/test_completions.py +++ b/tests/test_completions.py @@ -1,12 +1,16 @@ from pathlib import Path -from unittest.mock import patch +from unittest.mock import patch, MagicMock import pytest from click.shell_completion import CompletionItem from click.testing import CliRunner from nnote.cli import cli -from nnote.completions import complete_note_titles, complete_directories +from nnote.completions import ( + complete_note_titles, + complete_directories, + _zsh_install_path, +) from nnote.config import Config @@ -127,7 +131,7 @@ def test_show_completion(): def test_install_completion(tmp_path): script_file = tmp_path / "_nnote" with patch("nnote.completions._detect_shell", return_value="zsh"): - with patch("nnote.completions._COMPLETION_FILE", {"zsh": script_file}): + with patch("nnote.completions._zsh_install_path", return_value=script_file): result = CliRunner().invoke(cli, ["--install-completion"]) assert result.exit_code == 0 assert script_file.exists() @@ -138,8 +142,48 @@ def test_install_completion_idempotent(tmp_path): script_file = tmp_path / "_nnote" script_file.write_text("# existing script\n") with patch("nnote.completions._detect_shell", return_value="zsh"): - with patch("nnote.completions._COMPLETION_FILE", {"zsh": script_file}): + with patch("nnote.completions._zsh_install_path", return_value=script_file): result = CliRunner().invoke(cli, ["--install-completion"]) assert result.exit_code == 0 assert "already installed" in result.output assert script_file.read_text() == "# existing script\n" + + +def test_install_completion_bash(tmp_path): + script_file = tmp_path / "nnote" + with patch("nnote.completions._detect_shell", return_value="bash"): + with patch("nnote.completions._COMPLETION_FILE", {"bash": script_file}): + result = CliRunner().invoke(cli, ["--install-completion"]) + assert result.exit_code == 0 + assert script_file.exists() + assert "_NNOTE_COMPLETE" in script_file.read_text() + + +# --- _zsh_install_path --- + + +def test_zsh_install_path_picks_first_writable_home_fpath_dir(tmp_path): + user_dir = tmp_path / "zsh" / "functions" + user_dir.mkdir(parents=True) + system_dir = Path("/usr/share/zsh/functions") + + mock_result = MagicMock() + mock_result.stdout = f"{system_dir}\n{user_dir}\n" + + with patch("subprocess.run", return_value=mock_result): + with patch("nnote.completions.Path.home", return_value=tmp_path): + path = _zsh_install_path() + + assert path == user_dir / "_nnote" + + +def test_zsh_install_path_falls_back_to_xdg_when_no_home_fpath(tmp_path): + mock_result = MagicMock() + mock_result.stdout = ( + "/usr/share/zsh/functions\n/usr/local/share/zsh/site-functions\n" + ) + + with patch("subprocess.run", return_value=mock_result): + path = _zsh_install_path() + + assert path == Path("~/.local/share/zsh/site-functions/_nnote").expanduser()