From 67075c9a78a855c881694a7a4812728e82903cb4 Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 14:38:31 -0800 Subject: [PATCH] optmux-find --tags-path: return at once, fill the tags in the background The first call used to wait up to 120s for ctags, stalling vim at startup. It now prints the .abs.tags path immediately (empty until the build is done); the background build writes the absolute-path copy when it finishes. Co-Authored-By: Claude Sonnet 5.5 --- docs/usage-find.md | 2 +- optmux/data/bin/optmux-find | 50 ++++++++++++++++++++++--------------- tests/test_find.py | 12 +++++++++ 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/docs/usage-find.md b/docs/usage-find.md index 473b5dc..884fae8 100644 --- a/docs/usage-find.md +++ b/docs/usage-find.md @@ -5,7 +5,7 @@ - **`C-o` omni** (the default): tags, language-server symbols and file paths in one list, ranked together, so the stronger match is higher whichever it is (a file named for your query outranks a tag that only loosely contains it; a tag or symbol is matched on its name, a file on its path). A symbol the servers know replaces the tag for the same line. Rows are drawn alike in every source (see below); the best 100 are shown. Grep stays on its own key, `C-g`. - **`C-f` files**: `git ls-files` (tracked and untracked) first, then every other file `find` turns up, ignored ones included (scratch scripts, local notes), minus what `+find` → `+ignore_files` names (`node_modules`, `.venv`, caches, OPTMUX's own `.optmux.d`; see below). Just `find` outside a repo. - **`C-g` grep**: live `git grep`, re-run on every keystroke, smart-case. -- **`C-t` tags**: OPTMUX's own index, built the way a `post-commit` hook would (`git ls-files | ctags -L -`, minus `+ignore_tags` under `+find`), the first time, and refreshes it in the background whenever the commit or the tracked files that changed differ, so opening the picker never waits for it. Until the very first build is done (minutes in a huge project) the list is a single ⏳ row and fills in by itself when ctags finishes; later refreshes keep using the old tags until the new ones are ready. It needs [universal-ctags](https://ctags.io) (`brew install universal-ctags`, a dependency of the Homebrew formula; exuberant ctags should do too, minus line numbers it can find from patterns). macOS's own `/usr/bin/ctags` does not: it takes no file list and only knows C-like languages. It is stored per project in `.optmux.d/find/`. A `tags` file in the project is never read, whatever it holds (a hook's options, JSON keys, patterns without line numbers: the picker depends on what the index holds); `ctags` runs in the project root, so its `.ctags.d` still applies, and `+ignore_tags` (under `+find`) is the setting for what to leave out. To use the same tags in your editor, `optmux-find --tags-path` prints the file (building it first if there is none yet, and exiting 1 when there is no project; it prints a copy with absolute paths, since an editor reads them relative to the tags file's directory): in vim, `:let &tags .= ',' . escape(trim(system('optmux-find --tags-path 2>/dev/null')), ' ,')`. It resolves the same way inside and outside OPTMUX only where `$OPTMUX_DIR` is the same, so run it from a pane of the project's OPTMUX session. +- **`C-t` tags**: OPTMUX's own index, built the way a `post-commit` hook would (`git ls-files | ctags -L -`, minus `+ignore_tags` under `+find`), the first time, and refreshes it in the background whenever the commit or the tracked files that changed differ, so opening the picker never waits for it. Until the very first build is done (minutes in a huge project) the list is a single ⏳ row and fills in by itself when ctags finishes; later refreshes keep using the old tags until the new ones are ready. It needs [universal-ctags](https://ctags.io) (`brew install universal-ctags`, a dependency of the Homebrew formula; exuberant ctags should do too, minus line numbers it can find from patterns). macOS's own `/usr/bin/ctags` does not: it takes no file list and only knows C-like languages. It is stored per project in `.optmux.d/find/`. A `tags` file in the project is never read, whatever it holds (a hook's options, JSON keys, patterns without line numbers: the picker depends on what the index holds); `ctags` runs in the project root, so its `.ctags.d` still applies, and `+ignore_tags` (under `+find`) is the setting for what to leave out. To use the same tags in your editor, `optmux-find --tags-path` prints the file at once (starting the build if there are no tags yet: the file is empty until ctags finishes, and your editor picks the tags up on its next lookup; it exits 1 when there is no project; it prints a copy with absolute paths, since an editor reads them relative to the tags file's directory): in vim, `:let &tags .= ',' . escape(trim(system('optmux-find --tags-path 2>/dev/null')), ' ,')`. It resolves the same way inside and outside OPTMUX only where `$OPTMUX_DIR` is the same, so run it from a pane of the project's OPTMUX session. - **`C-l` lsp**: symbols from the project's language servers, searched as you type (see [Language servers](#language-servers-c-l) below). ## Switching, history and recency diff --git a/optmux/data/bin/optmux-find b/optmux/data/bin/optmux-find index ac98d0a..fea5cf7 100755 --- a/optmux/data/bin/optmux-find +++ b/optmux/data/bin/optmux-find @@ -250,6 +250,7 @@ def build_locked(root, out): try: if build_tags(root, out): write_tag_rows(out) + write_abs_tags(root, out) # (editors that were pointed at the path early now see the tags) else: out.with_suffix(".failed").touch() finally: @@ -306,29 +307,38 @@ def tags_building(): return lock_live(out) and not (out.is_file() and out.with_suffix(".rows").is_file()) +def write_abs_tags(root, found): + """Write the copy of tags `found` that an editor can use from anywhere (see tags_for_editors), replacing the + old one only once complete (an editor never reads half of it).""" + absolute = found.with_suffix(".abs.tags") + prefix = os.path.join(root, "").encode() + lines = [] + for line in found.read_bytes().split(b"\n"): # (bytes, split on newlines only: patterns may hold form feeds and the like) + name, tab, rest = line.partition(b"\t") + if tab and not name.startswith(b"!_"): + line = name + tab + prefix + rest if not rest.startswith(b"/") else line + lines.append(line) + temp = absolute.with_suffix(f".{os.getpid()}") + temp.write_bytes(b"\n".join(lines)) + os.replace(temp, absolute) + return absolute + + def tags_for_editors(): """Path of tags an editor can use from anywhere, or None. Ours name files relative to the project root, but an - editor reads them relative to the tags file (vim), which lives elsewhere: this copy has absolute paths.""" + editor reads them relative to the tags file (vim), which lives elsewhere: this copy has absolute paths. + Never waits for the first build: the path is the same from the start, an empty file until the background + build (see build_locked) fills it in, and an editor reads it afresh at each lookup.""" found = tags_file() - deadline = time.time() + 120 - while not found and tags_building() and time.time() < deadline: # (an editor wants tags now: wait for the first build) - time.sleep(0.2) - found = tags_file(check=False) root = repo_root() - if not found or not root: - return found - absolute = found.with_suffix(".abs.tags") - if not absolute.is_file() or absolute.stat().st_mtime < found.stat().st_mtime: - prefix = os.path.join(root, "").encode() - lines = [] - for line in found.read_bytes().split(b"\n"): # (bytes, split on newlines only: patterns may hold form feeds and the like) - name, tab, rest = line.partition(b"\t") - if tab and not name.startswith(b"!_"): - line = name + tab + prefix + rest if not rest.startswith(b"/") else line - lines.append(line) - temp = absolute.with_suffix(f".{os.getpid()}") - temp.write_bytes(b"\n".join(lines)) - os.replace(temp, absolute) # (an editor never reads half of it) + if not root or not (found or find_ctags()): + return None + absolute = tags_cache(root).with_suffix(".abs.tags") + if found and (not absolute.is_file() or absolute.stat().st_mtime < found.stat().st_mtime): + write_abs_tags(root, found) + elif not absolute.is_file(): + absolute.parent.mkdir(parents=True, exist_ok=True) + absolute.touch() # (a placeholder: the first build is under way) return absolute @@ -1210,7 +1220,7 @@ if __name__ == "__main__": os.chdir(sys.argv[2]) build_locked(sys.argv[2], tags_cache(sys.argv[2])) sys.exit(0) - if sys.argv[1:2] == ["--tags-path"]: # for an editor: `set tags+=$(optmux-find --tags-path)`; builds them if need be + if sys.argv[1:2] == ["--tags-path"]: # for an editor: `set tags+=$(optmux-find --tags-path)`; starts building them if need be, never waits found = tags_for_editors() if found: print(found) diff --git a/tests/test_find.py b/tests/test_find.py index a928886..eaf66f3 100644 --- a/tests/test_find.py +++ b/tests/test_find.py @@ -914,3 +914,15 @@ def test_rows_not_yet_drawn_while_a_build_is_on_are_waited_for_not_drawn_twice(r assert [k for k, _ in fd.tag_entries()] # the old rows serve meanwhile fd.tags_cache(str(repo)).with_suffix(".rows").unlink() assert list(fd.tag_entries()) == [] and fd.PENDING == [("tags",)] + + +def test_tags_path_does_not_wait_for_the_first_build(repo, monkeypatch, tmp_path_factory): + monkeypatch.setenv("OPTMUX_DIR", str(tmp_path_factory.mktemp("od4"))) + monkeypatch.setattr(fd, "find_ctags", lambda: "/bin/ctags") + monkeypatch.setattr(fd, "refresh_tags", lambda root: None) # (a build that takes its time) + path = fd.tags_for_editors() + assert path.name.endswith(".abs.tags") and path.read_bytes() == b"" # at once, empty for now + out = fd.tags_cache(str(repo)) + out.write_text('foo\ta.py\t1;"\tf\n') + fd.write_abs_tags(str(repo), out) # what the build does when done + assert path.read_text() == f'foo\t{repo}/a.py\t1;"\tf\n'