From 96229575e6641b9c294cf45ed0d9c530a94ffca9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 13 Mar 2026 13:07:49 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20dev=20tooling=20=E2=80=94=20wm-ts-nav?= =?UTF-8?q?=20navigator,=20format=20hooks,=20review=20skill=20(#8337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove wm-cursor, add local-review skill, update PR skill for EE - Remove the unused wm-cursor script and all references to it in README_WORKMUX_DEV.md and worktree-common.sh - Add /local-review skill for code review (bugs + CLAUDE.md compliance) - Add EE companion PR workflow to the /pr skill Co-Authored-By: Claude Opus 4.6 * chore: add wm-ts-nav tree-sitter navigator and fix format hooks - Add wm-ts-nav: standalone tree-sitter code navigator with SQLite index for fast symbol search, definition lookup, and file outlines across Rust, TypeScript, and Svelte files (~12ms warm, ~1s cold for 482 files) - Fix format hooks: surface errors instead of swallowing with 2>/dev/null, use direct prettier path with svelte plugin, add success feedback - Add wm-ts-nav commands to settings allow list - Document wm-ts-nav usage in CLAUDE.md Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): add refs command and --parent filter - refs: find usages of a symbol in code, skipping comments and strings (tree-sitter AST walk, ~46ms for 482 files vs grep's 4ms but no noise) - --parent filter on search: find all methods on a type across all files (e.g. search "%" --kind function --parent ServiceName) - Update CLAUDE.md with clearer when-to-use guidance Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): index refs in DB with import-path resolution Co-Authored-By: Claude Opus 4.5 * feat(wm-ts-nav): add body, callers, callees commands and refs --file/--caller - body: extract a symbol's source code from disk using indexed line ranges - callers: cross-file call graph via SQL join of refs + symbols tables - callees: list all identifiers referenced within a symbol's body - refs --file: scope results to files matching a substring - refs --caller: annotate each ref with the containing function name Co-Authored-By: Claude Opus 4.6 * feat(wm-ts-nav): add auto-rebuilding wrapper script The `wm-ts-nav/nav` wrapper checks if source files are newer than the binary and rebuilds automatically. Invoked via `sh wm-ts-nav/nav` to avoid needing executable permissions after clone. Co-Authored-By: Claude Opus 4.6 * docs: tighten CLAUDE.md nav section for actionable guidance Remove redundant question→command mapping, latency numbers, and excessive examples. Lead with "prefer wm-ts-nav over Read to save context window" and keep only the patterns that change behavior. Co-Authored-By: Claude Opus 4.6 * chore: revert backend/Cargo.lock to main Co-Authored-By: Claude Opus 4.6 * chore: promote wm-ts-nav in workflow, copy binary to worktrees - CLAUDE.md: integrate wm-ts-nav into Workflow step 1 and Core Principles so agents use outline/body before full file reads - workmux: copy built binary via files.copy - worktree-common.sh: copy binary in wm_copy_dependencies for webmux Co-Authored-By: Claude Opus 4.6 * fix(wm-ts-nav): fix double indexing, add TSX grammar, remove needless clone - Reuse index stats from the pre-query update instead of indexing twice on the Index command - Add Lang::Tsx variant so .tsx/.jsx files use LANGUAGE_TSX instead of LANGUAGE_TYPESCRIPT (Svelte stays on TS since script blocks are pure TS) - Remove source.clone() for non-Svelte files — move directly instead Co-Authored-By: Claude Opus 4.6 * fix(wm-ts-nav): fix svelte line numbers, add class methods, innermost caller Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 --- .claude/hooks/format-backend.sh | 6 +- .claude/hooks/format-frontend.sh | 6 +- .claude/settings.json | 6 + .claude/skills/local-review/SKILL.md | 98 ++++ .claude/skills/pr/SKILL.md | 23 + .workmux.yaml | 1 + CLAUDE.md | 38 +- README_WORKMUX_DEV.md | 78 --- docs/enterprise.md | 15 +- scripts/wm-cursor | 401 --------------- scripts/worktree-common.sh | 8 +- wm-ts-nav/Cargo.lock | 620 +++++++++++++++++++++++ wm-ts-nav/Cargo.toml | 21 + wm-ts-nav/nav | 10 + wm-ts-nav/src/db.rs | 422 ++++++++++++++++ wm-ts-nav/src/indexer.rs | 102 ++++ wm-ts-nav/src/main.rs | 270 ++++++++++ wm-ts-nav/src/parser.rs | 716 +++++++++++++++++++++++++++ 18 files changed, 2351 insertions(+), 490 deletions(-) create mode 100644 .claude/skills/local-review/SKILL.md delete mode 100755 scripts/wm-cursor create mode 100644 wm-ts-nav/Cargo.lock create mode 100644 wm-ts-nav/Cargo.toml create mode 100755 wm-ts-nav/nav create mode 100644 wm-ts-nav/src/db.rs create mode 100644 wm-ts-nav/src/indexer.rs create mode 100644 wm-ts-nav/src/main.rs create mode 100644 wm-ts-nav/src/parser.rs diff --git a/.claude/hooks/format-backend.sh b/.claude/hooks/format-backend.sh index d6077d7482..2b77f71432 100755 --- a/.claude/hooks/format-backend.sh +++ b/.claude/hooks/format-backend.sh @@ -13,8 +13,10 @@ fi # Check if the file is in the backend directory and is a Rust file if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then cd "$CLAUDE_PROJECT_DIR/backend" || exit 0 - # Run rustfmt with config from rustfmt.toml (edition=2021) - rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>/dev/null || true + # Run rustfmt, surface errors as context but don't block Claude + if rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi exit 0 diff --git a/.claude/hooks/format-frontend.sh b/.claude/hooks/format-frontend.sh index d0b4f0559b..37c3f8d4ec 100755 --- a/.claude/hooks/format-frontend.sh +++ b/.claude/hooks/format-frontend.sh @@ -15,8 +15,10 @@ if [[ "$FILE_PATH" == *"/frontend/"* ]]; then # Check if it's a formattable file type if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0 - # Run prettier silently, don't fail the hook if prettier fails - npx prettier --write "$FILE_PATH" 2>/dev/null || true + # Run prettier, surface errors as context but don't block Claude + if ./node_modules/.bin/prettier --plugin prettier-plugin-svelte --write "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi fi diff --git a/.claude/settings.json b/.claude/settings.json index cf8bfdd284..0596b17e91 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -28,6 +28,12 @@ "Bash(git show:*)", "Bash(git blame:*)", "Bash(cargo check:*)", + "Bash(cargo build --release:*)", + "Bash(sh wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/nav:*)", + "Bash(./wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/target/release/wm-ts-nav:*)", + "Bash(./wm-ts-nav/target/release/wm-ts-nav:*)", "mcp__ide__getDiagnostics", "Bash(npm run generate-backend-client:*)", "Bash(npm run check:*)", diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md new file mode 100644 index 0000000000..f14f5608db --- /dev/null +++ b/.claude/skills/local-review/SKILL.md @@ -0,0 +1,98 @@ +--- +name: local-review +user_invocable: true +description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. +--- + +# Local Code Review Skill + +Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. + +## Review Philosophy + +- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time. +- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect. + +## What to Flag + +- Code that won't compile or parse (syntax errors, type errors, missing imports) +- Code that will definitely produce wrong results regardless of inputs +- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated) +- Security issues in introduced code (injection, auth bypass, data exposure) +- Incorrect logic that will fail in production + +## What NOT to Flag + +- Code style or quality concerns +- Potential issues that depend on specific inputs or runtime state +- Subjective suggestions or improvements +- Pre-existing issues not introduced by this PR +- Pedantic nitpicks a senior engineer wouldn't flag +- Issues a linter or type checker will catch +- General quality concerns unless explicitly prohibited in CLAUDE.md +- Issues silenced via lint ignore comments + +## Execution Steps + +1. **Determine the PR scope**: + - If an argument is provided, use it as the PR number or branch + - Otherwise, detect from the current branch vs main + - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` + +2. **Find relevant CLAUDE.md files**: + - Read the root `CLAUDE.md` + - Check for CLAUDE.md files in directories containing changed files + +3. **Get the diff and metadata**: + - `gh pr diff` or `git diff main...HEAD` for the full diff + - `gh pr view` or `git log main..HEAD --oneline` for context + +4. **Read changed files** where the diff alone is insufficient to understand context + +5. **Review for**: + - CLAUDE.md compliance — check each rule against the changed code + - Bugs and logic errors — will this code work correctly? + - Security issues — injection, auth, data exposure in new code + +6. **Self-validate each finding**: Before reporting, ask yourself: + - "Is this definitely a real issue, not a false positive?" + - "Would a senior engineer flag this in review?" + - If the answer to either is no, discard the finding + +7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) + +## Output Format + +``` +## Code review + +Found N issues: + +1. () + + +2. () + +``` + +If no issues are found: + +``` +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. +``` + +## Posting Comments (--comment flag) + +If the user passes `--comment`, post findings as inline PR comments using: + +```bash +gh pr review --comment --body "" +``` + +Or for inline comments on specific lines: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" +``` diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md index f0c4e822e4..ab67b58748 100644 --- a/.claude/skills/pr/SKILL.md +++ b/.claude/skills/pr/SKILL.md @@ -33,6 +33,7 @@ Follow conventional commit format for the PR title: - Keep under 70 characters - Use lowercase, imperative mood - No period at the end +- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : ` ## PR Body Format @@ -85,3 +86,25 @@ Generated with [Claude Code](https://claude.com/claude-code) )" ``` 7. Return the PR URL to the user + +## EE Companion PR (when `*_ee.rs` files were modified) + +The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. + +Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details: + +1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md` +2. Check for changes: `git -C status --short` + - If there are no changes in the EE repo, skip this entire section +3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md` +4. Create the companion PR (title does NOT get the `[ee]` prefix): + ```bash + gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' + Companion PR for windmill-labs/windmill# + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +5. Commit `ee-repo-ref.txt` and push the updated windmill branch diff --git a/.workmux.yaml b/.workmux.yaml index 46049109c0..b36a8f16bf 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -67,6 +67,7 @@ files: copy: - backend/.env - scripts/ + - wm-ts-nav/target/release/wm-ts-nav sandbox: enabled: false diff --git a/CLAUDE.md b/CLAUDE.md index fe22fae0f7..f77dbb0600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background ## Workflow -1. **Understand**: Before coding, read relevant docs from `docs/` to understand the area you're changing +1. **Understand**: Before coding, use `wm-ts-nav` to explore (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code. Read `docs/` for domain context. 2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`) 4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` @@ -15,6 +15,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Backend patterns**: use the `rust-backend` skill when writing Rust code - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. +- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` @@ -49,8 +50,43 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() 2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value. +## Code Navigation + +`wm-ts-nav` is an AST-aware code navigator. Use **Grep** for regex/pattern search. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. + +**Prefer wm-ts-nav over Read** to save context window: +- `outline ` instead of reading a full file — understand structure first, then `body` or Read for specifics +- `body "X"` instead of reading a full file to see one function/struct +- `refs "X" --caller` instead of reading files to find which function contains each reference +- `callers "X"` / `callees "X"` for call-graph questions + +```bash +NAV="sh wm-ts-nav/nav" +# Use --root backend for Rust, --root frontend/src for TS/Svelte +$NAV --root backend outline backend/path/to/file.rs # file structure +$NAV --root backend def "ServiceName" # find definition +$NAV --root backend body "decrypt_oauth_data" # extract source code +$NAV --root backend search "%" --parent ServiceName # methods on a type +$NAV --root backend search "Trigger" --kind struct # find by kind +$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller +$NAV --root backend callers "X" # who calls X? +$NAV --root backend callees "X" # what does X call? +``` + +**Limitations** — syntax-level analysis, no type inference: +- Import paths are stored literally — `crate::X` and `super::X` pointing to the same type won't be linked +- Re-export chains (`pub use`) aren't followed — refs through different re-export paths won't connect +- Trait methods can't be resolved to their trait definition +- Nested `use` trees (`use foo::{bar::{A, B}, baz::C}`) aren't parsed correctly +- Glob imports (`use foo::*`) — refs won't show import origin +- Macro-generated symbols (e.g. `sqlx::FromRow`) — invisible to tree-sitter +- Single-char identifiers — intentionally filtered out of refs +- `callees` shows all identifiers in a function body, not just actual calls +- `import * as ns` namespace imports — member accesses through `ns.X` aren't resolved + ## Core Principles +- **Use `outline`/`body` to explore, then `Read` with offset/limit from the results before editing** — avoid reading full files - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked diff --git a/README_WORKMUX_DEV.md b/README_WORKMUX_DEV.md index 0b113e47ee..20d16d0352 100644 --- a/README_WORKMUX_DEV.md +++ b/README_WORKMUX_DEV.md @@ -192,70 +192,6 @@ sandbox: This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container. -## Cursor SSH Integration (`wmc`) - -`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree. - -This uses **grouped tmux sessions** — multiple sessions that share the same window list but track focus independently: - -``` -tmux session: main <-- your main Cursor terminal -tmux session: cursor-feat-a <-- Cursor window for feat-a (focused on wm-feat-a) -tmux session: cursor-feat-b <-- Cursor window for feat-b (focused on wm-feat-b) - \__ all three share the same windows in the status bar -``` - -### Setup - -Run once from inside tmux on the remote: - -```bash -./scripts/wm-cursor setup /home/hugo/projects/windmill -``` - -This: - -1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved. -2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens. -3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window. -4. **Adds `eval "$(wmc completions)"`** to `~/.zshrc` — provides tab-completion for subcommands and worktree names (for `open`, `open-ee`, and `close`). - -After setup, reopen Cursor's terminal to pick up the new profile. - -### Usage - -All commands run from inside a tmux session (i.e., from Cursor's integrated terminal after setup). - -**Create a new worktree + open Cursor:** - -```bash -wmc add -A -p "implement feature X" -``` - -This runs `workmux add`, creates a grouped tmux session, writes `.vscode/settings.json` in the worktree (with port forwarding matching the worktree's assigned ports), and opens a new Cursor window. - -**Open Cursor for an existing worktree:** - -```bash -wmc open my-feature -``` - -**Open the EE worktree in Cursor (no tmux session):** - -```bash -wmc open-ee my-feature -``` - -This finds the matching `windmill-ee-private__worktrees/` directory and opens it in a new Cursor window. - -**Close a worktree's Cursor window and tmux window (keeps the worktree):** - -```bash -wmc close my-feature -``` - -This kills the grouped tmux session and calls `workmux close` to close the tmux window. The worktree and branch are preserved. Grouped sessions are also automatically cleaned up when you `workmux rm` a worktree (via `scripts/worktree-cleanup`). - ## Cargo Features To build the backend with specific Cargo features (e.g., `enterprise`, `parquet`), pass them via `CARGO_FEATURES`. The backend pane reads this from `.env.local` and appends `--features ` to the `cargo watch` command. @@ -270,20 +206,6 @@ CARGO_FEATURES="enterprise,parquet" wm add my-feature This gets written to `.env.local` by the `post_create` hook (`scripts/worktree-env`), and the backend pane picks it up automatically. -**With `wmc` (wm-cursor):** - -Use the `--features` flag: - -```bash -# Create a new worktree with features -wmc add --features "enterprise,parquet" -A -p "implement feature X" - -# Open an existing worktree with different features -wmc open my-feature --features "enterprise,parquet" -``` - -The `--features` flag exports `CARGO_FEATURES` so the `post_create` hook writes it to `.env.local`. When using `wmc open`, it updates the existing `.env.local` with the new features. - ## Login Default credentials: `admin@windmill.dev` / `changeme` diff --git a/docs/enterprise.md b/docs/enterprise.md index bfed61a2e6..5e2b7c9b61 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -15,17 +15,22 @@ - Standard location: `~/windmill-ee-private` - Worktree location: `~/windmill-ee-private__worktrees//` +## Detecting EE Changes + +The `*_ee.rs` files in the windmill repo are symlinks — changes won't appear in `git diff` of the windmill repo. Check the EE repo directly: `git -C status --short` + ## EE PR Workflow (MUST DO when modifying `*_ee.rs` files) When you modify any `*_ee.rs` file and create a PR on windmill: -1. **Create a matching branch** in `windmill-ee-private` (same branch name) -2. **Commit and push** the `_ee.rs` changes in that branch -3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR -4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` +1. **Prefix the windmill PR title** with `[ee]`: `[ee] : ` +2. **Create a matching branch** in `windmill-ee-private` (same branch name) +3. **Commit and push** the `_ee.rs` changes in that branch +4. **Create a companion PR** on `windmill-ee-private` with a link to the windmill PR (no `[ee]` prefix on this one) +5. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` - **Verify** it wrote the correct commit hash from your branch, not from main (the script may fall back to `~/windmill-ee-private` on main) - If wrong, manually write the correct hash -5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref +6. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref ## Validation diff --git a/scripts/wm-cursor b/scripts/wm-cursor deleted file mode 100755 index 3c47da1179..0000000000 --- a/scripts/wm-cursor +++ /dev/null @@ -1,401 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -setopt err_exit no_unset pipe_fail - -# wm-cursor: Manage Cursor SSH remote windows with grouped tmux sessions -# Each worktree gets its own Cursor window with an independently-focused -# grouped tmux session, sharing the same window list in the status bar. - -# --- Resolve script path (must be at top level, not inside a function) --- - -local script_path=${0:A} - -# --- Lazy Cursor CLI resolution (only when needed) --- - -local cursor_bin= - -resolve_cursor_cli() { - [[ -n $cursor_bin ]] && return 0 - - local -a cursor_bins=(~/.cursor-server/cli/servers/*/server/bin/remote-cli/cursor(NOm)) - if (( ${#cursor_bins} == 0 )); then - print -u2 "Error: Cursor remote CLI not found in ~/.cursor-server/cli/servers/" - exit 1 - fi - cursor_bin=${cursor_bins[1]} - - # Refresh Cursor IPC socket (tmux may hold a stale one) - # Multiple stale sockets may exist; probe to find a live one - local sock - for sock in /tmp/vscode-ipc-*.sock(NOm); do - if timeout 2 env VSCODE_IPC_HOOK_CLI=$sock $cursor_bin --status &>/dev/null; then - export VSCODE_IPC_HOOK_CLI=$sock - break - fi - done -} - -# --- Helper functions --- - -ensure_tmux() { - if [[ -z ${TMUX-} ]]; then - print -u2 "Error: Not inside a tmux session" - exit 1 - fi -} - -check_dev_db() { - if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^windmill-db-dev$'; then - print -u2 "Warning: windmill-db-dev container is not running" - fi -} - -setup_grouped_session() { - local handle=$1 worktree_path=$2 - local session_name=cursor-${handle} - - # Detect the current main tmux session - local main_session=$(tmux display-message -p '#S') - - # Create grouped session (shares windows with the main session) - if ! tmux has-session -t $session_name 2>/dev/null; then - tmux new-session -d -t $main_session -s $session_name - fi - - # Focus on the worktree's window - tmux select-window -t ${session_name}:wm-${handle} 2>/dev/null || true - - # Write .vscode/settings.json in the worktree if it doesn't already exist - local settings_file=${worktree_path}/.vscode/settings.json - if [[ ! -f $settings_file ]]; then - mkdir -p ${settings_file:h} - - # Read ports from .env.local if available - local env_file=${worktree_path}/.env.local - local ports_config="" - if [[ -f $env_file ]]; then - local backend_port frontend_port - source $env_file - backend_port=${BACKEND_PORT-} - frontend_port=${FRONTEND_PORT-} - if [[ -n $backend_port && -n $frontend_port ]]; then - ports_config=', - "remote.autoForwardPorts": true, - "remote.otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "remote.portsAttributes": { - "'$backend_port'": { "label": "Backend", "onAutoForward": "silent" }, - "'$frontend_port'": { "label": "Frontend", "onAutoForward": "openBrowserOnce" } - }' - fi - fi - - cat > $settings_file < from args, exports CARGO_FEATURES, returns remaining args. -# Usage: parse_features_flag "$@"; set -- "${remaining_args[@]}" - -parse_flags() { - remaining_args=() - while (( $# )); do - case $1 in - --features) - if (( $# < 2 )); then - print -u2 "Error: --features requires a value" - exit 1 - fi - export CARGO_FEATURES=$2 - shift 2 - ;; - --features=*) - export CARGO_FEATURES=${1#--features=} - shift - ;; - --clone-db) - export WM_CLONE_DB=1 - shift - ;; - *) - remaining_args+=("$1") - shift - ;; - esac - done -} - -# --- Subcommands --- - -cmd_add() { - resolve_cursor_cli - ensure_tmux - check_dev_db - - parse_flags "$@" - set -- "${remaining_args[@]}" - - # Snapshot worktree list before - local -a before=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") - - workmux add -b "$@" - - # Diff to find the new entry - local -a after=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") - local -a new=(${after:|before}) - - if (( ${#new} == 0 )); then - print -u2 "Error: Could not detect new worktree path" - exit 1 - fi - - local new_path=${new[1]#worktree } - local handle=${new_path:t} - - print "New worktree: ${handle} at ${new_path}" - setup_grouped_session $handle $new_path - $cursor_bin -n $new_path - print "Opened Cursor for ${handle}" -} - -cmd_open() { - local name=${1:?Usage: wm-cursor open } - shift - - resolve_cursor_cli - ensure_tmux - check_dev_db - - parse_flags "$@" - set -- "${remaining_args[@]}" - - # Write CARGO_FEATURES to .env.local if specified - if [[ -n ${CARGO_FEATURES-} ]]; then - local wt_env=$(workmux path $name)/.env.local - if [[ -f $wt_env ]]; then - # Remove existing CARGO_FEATURES line and append new one - sed -i '/^CARGO_FEATURES=/d' $wt_env - echo "CARGO_FEATURES=$CARGO_FEATURES" >> $wt_env - fi - fi - - local wt_path=$(workmux path $name) - local prev_target=$(tmux display-message -p '#{session_name}:#{window_index}') - - workmux open $name "$@" - tmux select-window -t $prev_target - setup_grouped_session $name $wt_path - $cursor_bin -n $wt_path - print "Opened Cursor for ${name}" -} - -cmd_close() { - local name=${1:?Usage: wm-cursor close } - - tmux kill-session -t cursor-${name} 2>/dev/null || true - workmux close $name -} - -cmd_open_ee() { - local name=${1:?Usage: wm-cursor open-ee } - - resolve_cursor_cli - local wt_path=$(workmux path $name) - local main_repo_root="$(cd "$(git -C "$wt_path" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)" - - # Find ee repo (same discovery logic as worktree-env) - local ee_repo="" candidate - for candidate in \ - "${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \ - "${wt_path}/../windmill-ee-private" \ - "${HOME}/windmill-ee-private" \ - "${HOME}/projects/windmill-ee-private"; do - if [[ -n $candidate ]] && [[ -d $candidate ]]; then - ee_repo=${candidate:A} - break - fi - done - - if [[ -z $ee_repo ]]; then - print -u2 "Error: Could not find windmill-ee-private repo" - exit 1 - fi - - local ee_worktree_dir="${ee_repo}__worktrees/${name}" - if [[ ! -d $ee_worktree_dir ]]; then - print -u2 "Error: EE worktree not found at ${ee_worktree_dir}" - exit 1 - fi - - $cursor_bin -n $ee_worktree_dir - print "Opened Cursor for EE worktree: ${ee_worktree_dir}" -} - -cmd_setup() { - local repo_root=${1:?Usage: wm-cursor setup } - repo_root=${repo_root:A} - local vscode_dir=${repo_root}/.vscode - - mkdir -p $vscode_dir - - # --- tasks.json --- - local tasks_file=${vscode_dir}/tasks.json - local write_tasks=true - if [[ -f $tasks_file ]]; then - print -n "tasks.json already exists. Overwrite? [y/N] " - read -q || { print; write_tasks=false } - print - fi - if $write_tasks; then - cat > $tasks_file <<'TASKS' -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Start dev DB", - "type": "shell", - "command": "./start-dev-db.sh", - "options": { "shell": { "executable": "/bin/bash" } }, - "runOptions": { "runOn": "folderOpen" }, - "presentation": { "reveal": "silent", "close": true }, - "problemMatcher": [] - } - ] -} -TASKS - print "Wrote ${tasks_file}" - fi - - # --- settings.json (merge wm-cursor keys, preserve existing) --- - local settings_file=${vscode_dir}/settings.json - local wmc_settings=' - { - "rust-analyzer.initializeStopped": true, - "terminal.integrated.defaultProfile.linux": "wm-tmux", - "terminal.integrated.profiles.linux": { - "wm-tmux": { - "path": "tmux", - "args": ["new-session", "-A", "-s", "main"] - } - }, - "remote.autoForwardPorts": true, - "remote.otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "remote.portsAttributes": { - "8000": { "label": "Backend", "onAutoForward": "silent" }, - "3000": { "label": "Frontend", "onAutoForward": "openBrowserOnce" }, - "5432": { "label": "PostgreSQL", "onAutoForward": "silent" } - } - }' - - if [[ -f $settings_file ]]; then - # Strip // comments so jq can parse, merge, then write back - local existing - existing=$(python3 -c ' -import json, re, sys -text = sys.stdin.read() -# Remove // comments only outside of strings -text = re.sub(r'"'"'("(?:[^"\\]|\\.)*")|//[^\n]*'"'"', lambda m: m.group(1) or "", text) -json.dump(json.loads(text), sys.stdout, indent=2) -' < $settings_file) - jq --argjson wmc "$wmc_settings" '. * $wmc' <<< "$existing" > ${settings_file}.tmp \ - && mv ${settings_file}.tmp $settings_file - print "Merged wm-cursor settings into ${settings_file}" - else - jq . <<< "$wmc_settings" > $settings_file - print "Created ${settings_file}" - fi - - # --- zsh alias + completion --- - local rc=${ZDOTDIR:-$HOME}/.zshrc - local alias_line="alias wmc=${(q)script_path}" - - if [[ -f $rc ]] && grep -qF 'alias wmc=' $rc; then - sed -i "s|^alias wmc=.*|${alias_line}|" $rc - print "Updated wmc alias in ${rc}" - else - print "\n# wm-cursor alias\n${alias_line}" >> $rc - print "Added wmc alias to ${rc}" - fi - - local eval_line='eval "$(wmc completions)"' - if ! grep -qF 'wmc completions' $rc; then - print "${eval_line}" >> $rc - print "Added completions eval to ${rc}" - fi -} - -cmd_completions() { - cat <<'COMP' -_wmc_worktree_names() { - local -a names - names=(${(f)"$(git worktree list --porcelain 2>/dev/null | sed -n 's|^worktree .*/||p' | tail -n +2)"}) - _describe 'worktree' names -} - -_wmc() { - local -a subcmds=( - 'add:Create worktree + open Cursor' - 'open:Open Cursor for existing worktree' - 'open-ee:Open EE worktree in Cursor' - 'close:Clean up grouped tmux session' - 'setup:Set up .vscode settings, tasks + wmc alias' - 'completions:Print zsh completions' - ) - - if (( CURRENT == 2 )); then - _describe 'subcommand' subcmds - else - case $words[2] in - open|open-ee|close) - _wmc_worktree_names - ;; - esac - fi -} - -compdef _wmc wmc wm-cursor -COMP -} - -# --- Main --- - -case ${1-} in - add) shift; cmd_add "$@" ;; - open) shift; cmd_open "$@" ;; - open-ee) shift; cmd_open_ee "$@" ;; - close) shift; cmd_close "$@" ;; - setup) shift; cmd_setup "$@" ;; - completions) cmd_completions ;; - *) - print -u2 "Usage: wm-cursor [args...]" - print -u2 "" - print -u2 "Subcommands:" - print -u2 " add [--features ] [workmux-add-args...] Create worktree + open Cursor" - print -u2 " open [--features ] Open Cursor for existing worktree" - print -u2 " open-ee Open EE worktree in Cursor" - print -u2 " close Clean up grouped tmux session" - print -u2 " setup Set up .vscode settings, tasks + wmc alias" - print -u2 " completions Print zsh completions (use with eval)" - print -u2 "" - print -u2 "Options:" - print -u2 " --features Cargo features for the backend (e.g. \"enterprise,parquet\")" - print -u2 " --clone-db Clone the main 'windmill' database instead of creating an empty one" - exit 1 - ;; -esac diff --git a/scripts/worktree-common.sh b/scripts/worktree-common.sh index 91ba00a5e1..598469aad7 100755 --- a/scripts/worktree-common.sh +++ b/scripts/worktree-common.sh @@ -80,6 +80,13 @@ wm_copy_dependencies() { && echo "CLI deps installed and client generated" \ || echo "WARNING: CLI setup failed" >&2 fi + + local nav_bin="${main_repo_root}/wm-ts-nav/target/release/wm-ts-nav" + if [[ -f "$nav_bin" ]]; then + mkdir -p "${repo_root}/wm-ts-nav/target/release" + cp "$nav_bin" "${repo_root}/wm-ts-nav/target/release/" + echo "Copied wm-ts-nav binary" + fi } wm_allow_direnv() { @@ -261,5 +268,4 @@ wm_shared_pre_remove() { fi fi - tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true } diff --git a/wm-ts-nav/Cargo.lock b/wm-ts-nav/Cargo.lock new file mode 100644 index 0000000000..b0f1343e5d --- /dev/null +++ b/wm-ts-nav/Cargo.lock @@ -0,0 +1,620 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wm-ts-nav" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ignore", + "rayon", + "rusqlite", + "serde", + "serde_json", + "tree-sitter", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/wm-ts-nav/Cargo.toml b/wm-ts-nav/Cargo.toml new file mode 100644 index 0000000000..0dcf2c5081 --- /dev/null +++ b/wm-ts-nav/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wm-ts-nav" +version = "0.1.0" +edition = "2021" + +[dependencies] +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-typescript = "0.23" + +rusqlite = { version = "0.32", features = ["bundled"] } +rayon = "1.10" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +ignore = "0.4" + +[profile.release] +opt-level = 2 +lto = "thin" diff --git a/wm-ts-nav/nav b/wm-ts-nav/nav new file mode 100755 index 0000000000..3d0bbf3557 --- /dev/null +++ b/wm-ts-nav/nav @@ -0,0 +1,10 @@ +#!/bin/sh +# Auto-rebuilding wrapper for wm-ts-nav +DIR="$(cd "$(dirname "$0")" && pwd)" +BIN="$DIR/target/release/wm-ts-nav" + +if [ ! -f "$BIN" ] || [ -n "$(find "$DIR/src" "$DIR/Cargo.toml" -newer "$BIN" 2>/dev/null | head -1)" ]; then + cargo build --release --manifest-path "$DIR/Cargo.toml" >&2 || exit 1 +fi + +exec "$BIN" "$@" diff --git a/wm-ts-nav/src/db.rs b/wm-ts-nav/src/db.rs new file mode 100644 index 0000000000..eb6556262c --- /dev/null +++ b/wm-ts-nav/src/db.rs @@ -0,0 +1,422 @@ +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::parser::{IdentRef, Symbol}; + +pub struct Db { + conn: Connection, +} + +impl Db { + pub fn open(cache_dir: &Path) -> Result { + std::fs::create_dir_all(cache_dir) + .with_context(|| format!("creating cache dir: {}", cache_dir.display()))?; + let db_path = cache_dir.join("index.db"); + let conn = Connection::open(&db_path) + .with_context(|| format!("opening db: {}", db_path.display()))?; + + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + mtime_secs INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + signature TEXT, + parent TEXT + ); + CREATE TABLE IF NOT EXISTS refs ( + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + line INTEGER NOT NULL, + import_path TEXT + ); + CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name); + CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id); + CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind); + CREATE INDEX IF NOT EXISTS idx_files_path ON files(path); + CREATE INDEX IF NOT EXISTS idx_refs_name ON refs(name); + CREATE INDEX IF NOT EXISTS idx_refs_file ON refs(file_id);", + )?; + + Ok(Self { conn }) + } + + pub fn begin(&self) -> Result<()> { + self.conn.execute_batch("BEGIN")?; + Ok(()) + } + + pub fn commit(&self) -> Result<()> { + self.conn.execute_batch("COMMIT")?; + Ok(()) + } + + pub fn upsert_file( + &self, + path: &str, + mtime_secs: i64, + symbols: &[Symbol], + refs: &[IdentRef], + ) -> Result<()> { + // Delete old entry if exists + self.conn.execute( + "DELETE FROM refs WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn.execute( + "DELETE FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn + .execute("DELETE FROM files WHERE path = ?1", params![path])?; + + // Insert new file + self.conn.execute( + "INSERT INTO files (path, mtime_secs) VALUES (?1, ?2)", + params![path, mtime_secs], + )?; + let file_id = self.conn.last_insert_rowid(); + + // Insert symbols + let mut stmt = self.conn.prepare_cached( + "INSERT INTO symbols (file_id, name, kind, line, end_line, signature, parent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + )?; + for sym in symbols { + stmt.execute(params![ + file_id, + sym.name, + sym.kind, + sym.line, + sym.end_line, + sym.signature, + sym.parent, + ])?; + } + + // Insert refs + let mut ref_stmt = self.conn.prepare_cached( + "INSERT INTO refs (file_id, name, line, import_path) VALUES (?1, ?2, ?3, ?4)", + )?; + for r in refs { + ref_stmt.execute(params![file_id, r.name, r.line, r.import_path])?; + } + + Ok(()) + } + + pub fn remove_file(&self, path: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM refs WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn.execute( + "DELETE FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path = ?1)", + params![path], + )?; + self.conn + .execute("DELETE FROM files WHERE path = ?1", params![path])?; + Ok(()) + } + + pub fn all_indexed_paths(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT path, mtime_secs FROM files")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + } + + pub fn search_symbols( + &self, + pattern: &str, + kind_filter: Option<&str>, + parent_filter: Option<&str>, + limit: usize, + ) -> Result> { + let mut conditions = vec!["s.name LIKE ?1".to_string()]; + if let Some(kind) = kind_filter { + conditions.push(format!("s.kind = '{kind}'")); + } + if let Some(parent) = parent_filter { + conditions.push(format!("s.parent LIKE '%{parent}%'")); + } + let where_clause = conditions.join(" AND "); + let query = format!( + "SELECT s.name, s.kind, s.line, s.end_line, s.signature, s.parent, f.path + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE {where_clause} + ORDER BY s.name LIMIT ?2" + ); + + let like_pattern = if pattern.contains('%') || pattern.contains('_') { + pattern.to_string() + } else { + format!("%{pattern}%") + }; + + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![like_pattern, limit as i64], |row| { + Ok(SearchResult { + name: row.get(0)?, + kind: row.get(1)?, + line: row.get(2)?, + end_line: row.get(3)?, + signature: row.get(4)?, + parent: row.get(5)?, + path: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn file_symbols(&self, path: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT s.name, s.kind, s.line, s.end_line, s.signature, s.parent, f.path + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE f.path = ?1 + ORDER BY s.line", + )?; + let rows = stmt + .query_map(params![path], |row| { + Ok(SearchResult { + name: row.get(0)?, + kind: row.get(1)?, + line: row.get(2)?, + end_line: row.get(3)?, + signature: row.get(4)?, + parent: row.get(5)?, + path: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn find_refs( + &self, + name: &str, + limit: usize, + file_filter: Option<&str>, + with_caller: bool, + ) -> Result> { + let mut conditions = vec!["r.name = ?1".to_string()]; + if let Some(file) = file_filter { + conditions.push(format!("f.path LIKE '%{}'", file.replace('\'', "''"))); + } + let where_clause = conditions.join(" AND "); + + if with_caller { + let query = format!( + "SELECT path, line, import_path, caller_name, caller_kind FROM ( + SELECT f.path, r.line, r.import_path, s.name AS caller_name, s.kind AS caller_kind, + ROW_NUMBER() OVER ( + PARTITION BY r.file_id, r.line + ORDER BY (s.end_line - s.line) ASC + ) AS rn + FROM refs r + JOIN files f ON r.file_id = f.id + LEFT JOIN symbols s ON s.file_id = r.file_id + AND s.line <= r.line AND r.line <= s.end_line + AND s.kind IN ('function', 'impl', 'class', 'interface', 'method') + WHERE {where_clause} + ) WHERE rn = 1 + ORDER BY path, line + LIMIT ?2" + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(RefResult { + path: row.get(0)?, + line: row.get(1)?, + import_path: row.get(2)?, + caller_name: row.get(3)?, + caller_kind: row.get(4)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } else { + let query = format!( + "SELECT f.path, r.line, r.import_path + FROM refs r JOIN files f ON r.file_id = f.id + WHERE {where_clause} + ORDER BY f.path, r.line + LIMIT ?2" + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(RefResult { + path: row.get(0)?, + line: row.get(1)?, + import_path: row.get(2)?, + caller_name: None, + caller_kind: None, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + } + + pub fn find_callers(&self, name: &str, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT caller_name, caller_kind, caller_line, caller_end_line, path, ref_line FROM ( + SELECT s.name AS caller_name, s.kind AS caller_kind, + s.line AS caller_line, s.end_line AS caller_end_line, + f.path, r.line AS ref_line, + ROW_NUMBER() OVER ( + PARTITION BY r.file_id, r.line + ORDER BY (s.end_line - s.line) ASC + ) AS rn + FROM refs r + JOIN symbols s ON s.file_id = r.file_id + AND s.line <= r.line AND r.line <= s.end_line + AND s.kind IN ('function', 'impl', 'class', 'interface', 'method') + JOIN files f ON r.file_id = f.id + WHERE r.name = ?1 + ) WHERE rn = 1 + ORDER BY path, caller_line + LIMIT ?2", + )?; + let rows = stmt + .query_map(params![name, limit as i64], |row| { + Ok(CallerResult { + caller_name: row.get(0)?, + caller_kind: row.get(1)?, + caller_line: row.get(2)?, + caller_end_line: row.get(3)?, + path: row.get(4)?, + ref_line: row.get(5)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + pub fn find_callees( + &self, + name: &str, + kind_filter: Option<&str>, + file_filter: Option<&str>, + ) -> Result> { + // First find the symbol + let results = self.search_symbols(name, kind_filter, None, 100)?; + let exact: Vec<_> = results.into_iter().filter(|r| r.name == name).collect(); + if exact.is_empty() { + return Ok(vec![]); + } + + let mut all_callees = Vec::new(); + for sym in &exact { + if let Some(file) = file_filter { + if !sym.path.contains(file) { + continue; + } + } + let mut stmt = self.conn.prepare( + "SELECT DISTINCT r.name, r.import_path + FROM refs r + JOIN files f ON r.file_id = f.id + WHERE f.path = ?1 AND r.line >= ?2 AND r.line <= ?3 + ORDER BY r.name", + )?; + let rows = stmt + .query_map(params![sym.path, sym.line, sym.end_line], |row| { + Ok(CalleeResult { + name: row.get(0)?, + import_path: row.get(1)?, + }) + })? + .collect::, _>>()?; + all_callees.extend(rows); + } + // Deduplicate by name + all_callees.sort_by(|a, b| a.name.cmp(&b.name)); + all_callees.dedup_by(|a, b| a.name == b.name); + Ok(all_callees) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct RefResult { + pub path: String, + pub line: i64, + pub import_path: Option, + pub caller_name: Option, + pub caller_kind: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct CallerResult { + pub caller_name: String, + pub caller_kind: String, + pub caller_line: i64, + pub caller_end_line: i64, + pub path: String, + pub ref_line: i64, +} + +#[derive(Debug, serde::Serialize)] +pub struct CalleeResult { + pub name: String, + pub import_path: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct SearchResult { + pub name: String, + pub kind: String, + pub line: i64, + pub end_line: i64, + pub signature: Option, + pub parent: Option, + pub path: String, +} + +pub fn mtime_secs(path: &Path) -> Result { + let meta = std::fs::metadata(path)?; + let mtime = meta + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + Ok(mtime.as_secs() as i64) +} + +pub fn cache_dir_for(root: &Path) -> PathBuf { + let hash = { + let s = root.to_string_lossy(); + let mut h: u64 = 5381; + for b in s.bytes() { + h = h.wrapping_mul(33).wrapping_add(b as u64); + } + h + }; + dirs_cache().join(format!("{hash:x}")) +} + +fn dirs_cache() -> PathBuf { + if let Ok(d) = std::env::var("XDG_CACHE_HOME") { + PathBuf::from(d).join("wm-ts-nav") + } else if let Ok(d) = std::env::var("HOME") { + PathBuf::from(d).join(".cache").join("wm-ts-nav") + } else { + PathBuf::from("/tmp/wm-ts-nav") + } +} diff --git a/wm-ts-nav/src/indexer.rs b/wm-ts-nav/src/indexer.rs new file mode 100644 index 0000000000..ef69fbd1f4 --- /dev/null +++ b/wm-ts-nav/src/indexer.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use ignore::WalkBuilder; +use rayon::prelude::*; +use std::collections::HashSet; +use std::path::Path; + +use crate::db::{self, Db}; +use crate::parser::{self, Lang}; + +pub struct IndexStats { + pub files_scanned: usize, + pub files_updated: usize, + pub files_removed: usize, + pub files_unchanged: usize, +} + +/// Incrementally update the index for the given root directory. +/// Only re-parses files whose mtime has changed since last index. +pub fn update_index(db: &Db, root: &Path) -> Result { + // Collect all supported files using `ignore` crate (respects .gitignore) + let files: Vec<_> = WalkBuilder::new(root) + .hidden(true) + .git_ignore(true) + .git_global(false) + .build() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .filter(|e| Lang::from_path(e.path()).is_some()) + .map(|e| e.into_path()) + .collect(); + + let disk_paths: HashSet = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + // Check which files need updating + let existing = db.all_indexed_paths()?; + // Remove files no longer on disk + let mut files_removed = 0; + db.begin()?; + for (path, _) in &existing { + if !disk_paths.contains(path) { + db.remove_file(path)?; + files_removed += 1; + } + } + db.commit()?; + + // Figure out which files need re-parsing + let existing_map: std::collections::HashMap<&str, i64> = existing + .iter() + .map(|(p, m)| (p.as_str(), *m)) + .collect(); + + let to_parse: Vec<_> = files + .iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + match existing_map.get(path_str.as_ref()) { + Some(&old_mtime) => { + // Check if mtime changed + db::mtime_secs(path).unwrap_or(0) != old_mtime + } + None => true, // New file + } + }) + .collect(); + + let files_unchanged = files.len() - to_parse.len(); + + // Parse files in parallel + let results: Vec<_> = to_parse + .par_iter() + .filter_map(|path| { + let mtime = db::mtime_secs(path).ok()?; + match parser::parse_file(path) { + Ok(result) => Some((path.to_string_lossy().to_string(), mtime, result)), + Err(e) => { + eprintln!("warning: failed to parse {}: {e}", path.display()); + None + } + } + }) + .collect(); + + let files_updated = results.len(); + + // Write to db in a single transaction + db.begin()?; + for (path, mtime, result) in &results { + db.upsert_file(path, *mtime, &result.symbols, &result.refs)?; + } + db.commit()?; + + Ok(IndexStats { + files_scanned: files.len(), + files_updated, + files_removed, + files_unchanged, + }) +} diff --git a/wm-ts-nav/src/main.rs b/wm-ts-nav/src/main.rs new file mode 100644 index 0000000000..8ae8c11f5b --- /dev/null +++ b/wm-ts-nav/src/main.rs @@ -0,0 +1,270 @@ +mod db; +mod indexer; +mod parser; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "wm-ts-nav", about = "Tree-sitter code navigator for Windmill")] +struct Cli { + /// Root directory to index (defaults to current directory) + #[arg(short, long)] + root: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Index/re-index the codebase + Index, + /// Show symbols in a file + Outline { + /// File path + file: PathBuf, + }, + /// Search symbols by name pattern + Search { + /// Name pattern (supports SQL LIKE % wildcards) + pattern: String, + /// Filter by kind (function, struct, enum, trait, impl, etc.) + #[arg(short, long)] + kind: Option, + /// Filter by parent (e.g. --parent ServiceName to find methods on that type) + #[arg(short, long)] + parent: Option, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + }, + /// Find symbol definition by exact name + Def { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + }, + /// Find references to a symbol in code (skips comments and strings) + Refs { + /// Symbol name to find + name: String, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + /// Show which function/symbol contains each reference + #[arg(short, long)] + caller: bool, + }, + /// Extract and print a symbol's source code + Body { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + }, + /// Find what calls a symbol (who calls X?) + Callers { + /// Symbol name to find callers of + name: String, + /// Max results + #[arg(short, long, default_value = "50")] + limit: usize, + }, + /// Find what a symbol calls (what does X call?) + Callees { + /// Exact symbol name + name: String, + /// Filter by kind + #[arg(short, long)] + kind: Option, + /// Filter to files matching this substring + #[arg(short, long)] + file: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let root = cli + .root + .unwrap_or_else(|| std::env::current_dir().expect("no cwd")); + let root = std::fs::canonicalize(&root)?; + let cache_dir = db::cache_dir_for(&root); + let db = db::Db::open(&cache_dir)?; + + // Always update index incrementally before any query + let stats = indexer::update_index(&db, &root)?; + + match cli.command { + Command::Index => { + println!( + "Indexed {} files: {} updated, {} unchanged, {} removed", + stats.files_scanned, stats.files_updated, stats.files_unchanged, stats.files_removed + ); + } + Command::Outline { file } => { + let file = std::fs::canonicalize(&file)?; + let symbols = db.file_symbols(&file.to_string_lossy())?; + if symbols.is_empty() { + println!("No symbols found"); + return Ok(()); + } + for s in &symbols { + let parent = s + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + let sig = s + .signature + .as_deref() + .map(|s| format!(" {s}")) + .unwrap_or_default(); + println!("L{}-{} {:12} {}{}{}", s.line, s.end_line, s.kind, s.name, parent, sig); + } + } + Command::Search { + pattern, + kind, + parent, + limit, + } => { + let results = db.search_symbols(&pattern, kind.as_deref(), parent.as_deref(), limit)?; + if results.is_empty() { + println!("No symbols matching '{pattern}'"); + return Ok(()); + } + for r in &results { + let sig = r + .signature + .as_deref() + .map(|s| format!(" {s}")) + .unwrap_or_default(); + let parent_info = r + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + println!("{}:{} {:12} {}{}{}", r.path, r.line, r.kind, r.name, parent_info, sig); + } + } + Command::Def { name, kind } => { + let results = db.search_symbols(&name, kind.as_deref(), None, 100)?; + let exact: Vec<_> = results.iter().filter(|r| r.name == name).collect(); + if exact.is_empty() { + println!("No definition found for '{name}'"); + return Ok(()); + } + for r in &exact { + let sig = r + .signature + .as_deref() + .map(|s| format!("\n {s}")) + .unwrap_or_default(); + let parent = r + .parent + .as_deref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + println!( + "{}:L{}-{} {} {}{}{}", + r.path, r.line, r.end_line, r.kind, r.name, parent, sig + ); + } + } + Command::Refs { + name, + limit, + file, + caller, + } => { + let results = db.find_refs(&name, limit, file.as_deref(), caller)?; + if results.is_empty() { + println!("No references found for '{name}'"); + return Ok(()); + } + for r in &results { + let origin = r + .import_path + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + let caller_info = r + .caller_name + .as_deref() + .map(|c| format!(" [{c}]")) + .unwrap_or_default(); + println!("{}:{}{}{}", r.path, r.line, caller_info, origin); + } + } + Command::Body { name, kind, file } => { + let results = db.search_symbols(&name, kind.as_deref(), None, 100)?; + let mut exact: Vec<_> = results.into_iter().filter(|r| r.name == name).collect(); + if let Some(ref f) = file { + exact.retain(|r| r.path.contains(f.as_str())); + } + if exact.is_empty() { + println!("No definition found for '{name}'"); + return Ok(()); + } + for (i, r) in exact.iter().enumerate() { + if i > 0 { + println!("\n---\n"); + } + println!("{}:L{}-{}", r.path, r.line, r.end_line); + match std::fs::read_to_string(&r.path) { + Ok(contents) => { + let lines: Vec<&str> = contents.lines().collect(); + let start = (r.line as usize).saturating_sub(1); + let end = (r.end_line as usize).min(lines.len()); + for line in &lines[start..end] { + println!("{line}"); + } + } + Err(e) => println!(" (error reading file: {e})"), + } + } + } + Command::Callers { name, limit } => { + let results = db.find_callers(&name, limit)?; + if results.is_empty() { + println!("No callers found for '{name}'"); + return Ok(()); + } + for r in &results { + println!( + "{}:L{}-{} {} {} → L{}", + r.path, r.caller_line, r.caller_end_line, r.caller_kind, r.caller_name, r.ref_line + ); + } + } + Command::Callees { name, kind, file } => { + let results = db.find_callees(&name, kind.as_deref(), file.as_deref())?; + if results.is_empty() { + println!("No callees found for '{name}'"); + return Ok(()); + } + for r in &results { + let origin = r + .import_path + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + println!("{}{}", r.name, origin); + } + } + } + + Ok(()) +} diff --git a/wm-ts-nav/src/parser.rs b/wm-ts-nav/src/parser.rs new file mode 100644 index 0000000000..43d3bfe0af --- /dev/null +++ b/wm-ts-nav/src/parser.rs @@ -0,0 +1,716 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use tree_sitter::{Node, Parser}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Symbol { + pub name: String, + pub kind: String, + pub line: usize, + pub end_line: usize, + pub signature: Option, + pub parent: Option, +} + +#[derive(Debug, Clone)] +pub struct IdentRef { + pub name: String, + pub line: usize, + /// Resolved import path if known (e.g. "windmill_common::error::Error") + pub import_path: Option, +} + +/// A `use` import with its scope +#[derive(Debug, Clone)] +pub struct ImportEntry { + /// The short name (e.g. "Error") + pub name: String, + /// Full path (e.g. "windmill_common::error::Error") + pub full_path: String, + /// Line where the use is declared + pub line: usize, + /// End of the scope this use lives in (file end for top-level, block end for scoped) + pub scope_end: usize, +} + +pub struct ParseResult { + pub symbols: Vec, + pub refs: Vec, +} + +pub enum Lang { + Rust, + Typescript, + Tsx, +} + +impl Lang { + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()? { + "rs" => Some(Self::Rust), + "tsx" | "jsx" => Some(Self::Tsx), + "ts" | "js" => Some(Self::Typescript), + "svelte" => Some(Self::Typescript), // we extract