chore: dev tooling — wm-ts-nav navigator, format hooks, review skill (#8337)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* feat(wm-ts-nav): index refs in DB with import-path resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore: revert backend/Cargo.lock to main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(wm-ts-nav): fix svelte line numbers, add class methods, innermost caller

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-03-13 13:07:49 +01:00
committed by GitHub
parent 2d5b72b3ce
commit 96229575e6
18 changed files with 2351 additions and 490 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:*)",

View File

@@ -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. <description> (<reason: CLAUDE.md adherence | bug | security>)
<file_path:line_number>
2. <description> (<reason>)
<file_path:line_number>
```
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 "<summary>"
```
Or for inline comments on specific lines:
```bash
gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="<summary>" -f event="COMMENT" -f comments="[...]"
```

View File

@@ -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] <type>: <description>`
## 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 <ee-path> status --short`
- If there are no changes in the EE repo, skip this entire section
3. Follow steps 15 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 "<type>: <description>" --body "$(cat <<'EOF'
Companion PR for windmill-labs/windmill#<PR_NUMBER>
---
Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
5. Commit `ee-repo-ref.txt` and push the updated windmill branch

View File

@@ -67,6 +67,7 @@ files:
copy:
- backend/.env
- scripts/
- wm-ts-nav/target/release/wm-ts-nav
sandbox:
enabled: false

View File

@@ -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 <file>` 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

View File

@@ -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/<name>` 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 <value>` 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`

View File

@@ -15,17 +15,22 @@
- Standard location: `~/windmill-ee-private`
- Worktree location: `~/windmill-ee-private__worktrees/<branch-name>/`
## 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 <ee-path> 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] <type>: <description>`
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

View File

@@ -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 <<SETTINGS
{
"rust-analyzer.initializeStopped": true,
"terminal.integrated.defaultProfile.linux": "wm-tmux",
"terminal.integrated.profiles.linux": {
"wm-tmux": {
"path": "tmux",
"args": ["attach-session", "-t", "${session_name}"]
}
}${ports_config}
}
SETTINGS
print "Created ${settings_file}"
fi
}
# --- Feature flag parsing ---
# Extracts --features <value> 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 <name>}
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 <name>}
tmux kill-session -t cursor-${name} 2>/dev/null || true
workmux close $name
}
cmd_open_ee() {
local name=${1:?Usage: wm-cursor open-ee <name>}
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=${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 <add|open|open-ee|close|setup|completions> [args...]"
print -u2 ""
print -u2 "Subcommands:"
print -u2 " add [--features <f>] [workmux-add-args...] Create worktree + open Cursor"
print -u2 " open <name> [--features <f>] Open Cursor for existing worktree"
print -u2 " open-ee <name> Open EE worktree in Cursor"
print -u2 " close <name> Clean up grouped tmux session"
print -u2 " setup <repo-root> Set up .vscode settings, tasks + wmc alias"
print -u2 " completions Print zsh completions (use with eval)"
print -u2 ""
print -u2 "Options:"
print -u2 " --features <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

View File

@@ -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
}

620
wm-ts-nav/Cargo.lock generated Normal file
View File

@@ -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"

21
wm-ts-nav/Cargo.toml Normal file
View File

@@ -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"

10
wm-ts-nav/nav Executable file
View File

@@ -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" "$@"

422
wm-ts-nav/src/db.rs Normal file
View File

@@ -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<Self> {
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<Vec<(String, i64)>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn search_symbols(
&self,
pattern: &str,
kind_filter: Option<&str>,
parent_filter: Option<&str>,
limit: usize,
) -> Result<Vec<SearchResult>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn file_symbols(&self, path: &str) -> Result<Vec<SearchResult>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn find_refs(
&self,
name: &str,
limit: usize,
file_filter: Option<&str>,
with_caller: bool,
) -> Result<Vec<RefResult>> {
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::<std::result::Result<Vec<_>, _>>()?;
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
}
pub fn find_callers(&self, name: &str, limit: usize) -> Result<Vec<CallerResult>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn find_callees(
&self,
name: &str,
kind_filter: Option<&str>,
file_filter: Option<&str>,
) -> Result<Vec<CalleeResult>> {
// 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::<std::result::Result<Vec<_>, _>>()?;
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<String>,
pub caller_name: Option<String>,
pub caller_kind: Option<String>,
}
#[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<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct SearchResult {
pub name: String,
pub kind: String,
pub line: i64,
pub end_line: i64,
pub signature: Option<String>,
pub parent: Option<String>,
pub path: String,
}
pub fn mtime_secs(path: &Path) -> Result<i64> {
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")
}
}

102
wm-ts-nav/src/indexer.rs Normal file
View File

@@ -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<IndexStats> {
// 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<String> = 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,
})
}

270
wm-ts-nav/src/main.rs Normal file
View File

@@ -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<PathBuf>,
#[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<String>,
/// Filter by parent (e.g. --parent ServiceName to find methods on that type)
#[arg(short, long)]
parent: Option<String>,
/// 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<String>,
},
/// 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<String>,
/// 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<String>,
/// Filter to files matching this substring
#[arg(short, long)]
file: Option<String>,
},
/// 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<String>,
/// Filter to files matching this substring
#[arg(short, long)]
file: Option<String>,
},
}
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(())
}

716
wm-ts-nav/src/parser.rs Normal file
View File

@@ -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<String>,
pub parent: Option<String>,
}
#[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<String>,
}
/// 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<Symbol>,
pub refs: Vec<IdentRef>,
}
pub enum Lang {
Rust,
Typescript,
Tsx,
}
impl Lang {
pub fn from_path(path: &Path) -> Option<Self> {
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 <script> block, which is pure TS
_ => None,
}
}
}
pub fn parse_file(path: &Path) -> Result<ParseResult> {
let lang = Lang::from_path(path).context("unsupported file type")?;
let source = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let code = match lang {
Lang::Typescript if path.extension().map(|e| e == "svelte").unwrap_or(false) => {
extract_svelte_script(&source)
}
_ => source,
};
let mut parser = Parser::new();
match lang {
Lang::Rust => {
parser.set_language(&tree_sitter_rust::LANGUAGE.into())?;
}
Lang::Typescript => {
parser.set_language(&tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())?;
}
Lang::Tsx => {
parser.set_language(&tree_sitter_typescript::LANGUAGE_TSX.into())?;
}
}
let tree = parser
.parse(&code, None)
.context("tree-sitter parse failed")?;
let root = tree.root_node();
let mut symbols = Vec::new();
match lang {
Lang::Rust => extract_rust_symbols(root, &code, &mut symbols, None),
Lang::Typescript | Lang::Tsx => extract_ts_symbols(root, &code, &mut symbols, None),
}
let mut imports = Vec::new();
let file_end = code.lines().count();
match lang {
Lang::Rust => collect_rust_imports(root, &code, &mut imports, file_end),
Lang::Typescript | Lang::Tsx => collect_ts_imports(root, &code, &mut imports, file_end),
}
let mut refs = Vec::new();
collect_ident_refs(root, &code, &mut refs);
// Resolve import paths for refs
resolve_refs(&mut refs, &imports);
Ok(ParseResult { symbols, refs })
}
fn extract_svelte_script(source: &str) -> String {
// Preserve original line positions: script lines stay at their original line numbers,
// non-script lines become empty. Tree-sitter then reports correct line numbers.
let mut result = String::new();
let mut in_script = false;
for line in source.lines() {
let trimmed = line.trim_start();
if !in_script && trimmed.starts_with("<script") {
result.push('\n');
in_script = true;
} else if in_script && trimmed.starts_with("</script") {
result.push('\n');
in_script = false;
} else if in_script {
result.push_str(line);
result.push('\n');
} else {
result.push('\n');
}
}
result
}
fn extract_rust_symbols(node: Node, source: &str, symbols: &mut Vec<Symbol>, parent: Option<&str>) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_item" => {
if let Some(sym) = rust_function(child, source, parent) {
symbols.push(sym);
}
}
"struct_item" => {
if let Some(name) = child_by_field(child, "name", source) {
let sig = signature_up_to_body(child, source);
symbols.push(Symbol {
name: name.clone(),
kind: "struct".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(sig),
parent: parent.map(String::from),
});
}
}
"enum_item" => {
if let Some(name) = child_by_field(child, "name", source) {
let sig = signature_up_to_body(child, source);
symbols.push(Symbol {
name: name.clone(),
kind: "enum".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(sig),
parent: parent.map(String::from),
});
}
}
"trait_item" => {
if let Some(name) = child_by_field(child, "name", source) {
let sig = signature_up_to_body(child, source);
symbols.push(Symbol {
name: name.clone(),
kind: "trait".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(sig),
parent: parent.map(String::from),
});
// Recurse into trait body for methods
if let Some(body) = child.child_by_field_name("body") {
extract_rust_symbols(body, source, symbols, Some(&name));
}
}
}
"impl_item" => {
let impl_name = rust_impl_name(child, source);
let sig = signature_up_to_body(child, source);
let parent_name = impl_name.as_deref().unwrap_or("impl");
symbols.push(Symbol {
name: parent_name.to_string(),
kind: "impl".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(sig),
parent: parent.map(String::from),
});
// Recurse into impl body for methods
if let Some(body) = child.child_by_field_name("body") {
extract_rust_symbols(body, source, symbols, Some(parent_name));
}
}
"type_item" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "type_alias".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(node_text(child, source).to_string()),
parent: parent.map(String::from),
});
}
}
"const_item" | "static_item" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: child.kind().trim_end_matches("_item").into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
}
}
"mod_item" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "mod".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: None,
parent: parent.map(String::from),
});
}
}
"macro_definition" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "macro".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: None,
parent: parent.map(String::from),
});
}
}
// Recurse into declaration_list (impl body, trait body)
"declaration_list" => {
extract_rust_symbols(child, source, symbols, parent);
}
_ => {}
}
}
}
fn rust_function(node: Node, source: &str, parent: Option<&str>) -> Option<Symbol> {
let name = child_by_field(node, "name", source)?;
let sig = rust_fn_signature(node, source);
Some(Symbol {
name,
kind: "function".into(),
line: node.start_position().row + 1,
end_line: node.end_position().row + 1,
signature: Some(sig),
parent: parent.map(String::from),
})
}
fn rust_fn_signature(node: Node, source: &str) -> String {
// Capture everything up to the block (the `{`)
let text = node_text(node, source);
if let Some(brace) = text.find('{') {
text[..brace].trim().to_string()
} else {
// No body (trait declaration)
text.lines().next().unwrap_or("").to_string()
}
}
fn rust_impl_name(node: Node, source: &str) -> Option<String> {
// impl [Trait for] Type
let mut cursor = node.walk();
let mut type_name = None;
let mut trait_name = None;
for child in node.children(&mut cursor) {
match child.kind() {
"type_identifier" | "scoped_type_identifier" | "generic_type" => {
if trait_name.is_none() && type_name.is_none() {
type_name = Some(node_text(child, source).to_string());
} else if type_name.is_some() {
// This is the type after "for"
trait_name = type_name.take();
type_name = Some(node_text(child, source).to_string());
}
}
_ => {}
}
}
match (trait_name, type_name) {
(Some(t), Some(ty)) => Some(format!("{t} for {ty}")),
(None, Some(ty)) => Some(ty),
_ => None,
}
}
fn extract_ts_symbols(node: Node, source: &str, symbols: &mut Vec<Symbol>, parent: Option<&str>) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_declaration" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "function".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
}
}
"interface_declaration" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "interface".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
}
}
"type_alias_declaration" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "type_alias".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(node_text(child, source).to_string()),
parent: parent.map(String::from),
});
}
}
"enum_declaration" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "enum".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
}
}
"class_declaration" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name: name.clone(),
kind: "class".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
if let Some(body) = child.child_by_field_name("body") {
extract_ts_symbols(body, source, symbols, Some(&name));
}
}
}
"method_definition" | "abstract_method_definition"
| "abstract_method_signature" | "method_signature" => {
if let Some(name) = child_by_field(child, "name", source) {
symbols.push(Symbol {
name,
kind: "method".into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
}
}
"public_field_definition" => {
if let Some(name) = child_by_field(child, "name", source) {
let kind = if let Some(value) = child.child_by_field_name("value") {
match value.kind() {
"arrow_function" | "function_expression" | "function" => "method",
_ => "property",
}
} else {
"property"
};
symbols.push(Symbol {
name,
kind: kind.into(),
line: child.start_position().row + 1,
end_line: child.end_position().row + 1,
signature: Some(signature_up_to_body(child, source)),
parent: parent.map(String::from),
});
}
}
"export_statement" | "program" => {
extract_ts_symbols(child, source, symbols, parent);
}
"lexical_declaration" | "variable_declaration" => {
// const/let/var foo = ...
ts_variable_decl(child, source, symbols, parent);
}
_ => {}
}
}
}
fn ts_variable_decl(
node: Node,
source: &str,
symbols: &mut Vec<Symbol>,
parent: Option<&str>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable_declarator" {
if let Some(name) = child_by_field(child, "name", source) {
// Check if the value is an arrow function or function expression
let kind = if let Some(value) = child.child_by_field_name("value") {
match value.kind() {
"arrow_function" | "function_expression" | "function" => "function",
_ => "const",
}
} else {
"const"
};
symbols.push(Symbol {
name,
kind: kind.into(),
line: node.start_position().row + 1,
end_line: node.end_position().row + 1,
signature: Some(signature_up_to_body(node, source)),
parent: parent.map(String::from),
});
}
}
}
}
fn child_by_field(node: Node, field: &str, source: &str) -> Option<String> {
Some(node_text(node.child_by_field_name(field)?, source).to_string())
}
fn node_text<'a>(node: Node, source: &'a str) -> &'a str {
&source[node.byte_range()]
}
fn signature_up_to_body(node: Node, source: &str) -> String {
let text = node_text(node, source);
// Find first `{` that starts a block body
if let Some(pos) = text.find('{') {
let sig = text[..pos].trim();
// Collapse whitespace
sig.split_whitespace().collect::<Vec<_>>().join(" ")
} else {
let first_line = text.lines().next().unwrap_or("");
first_line.trim().to_string()
}
}
/// Collect all identifier references in code, skipping comments and strings.
fn collect_ident_refs(node: Node, source: &str, refs: &mut Vec<IdentRef>) {
match node.kind() {
// Skip non-code nodes
"line_comment" | "block_comment" | "string_literal" | "raw_string_literal"
| "string" | "template_string" | "string_fragment" | "comment"
| "string_content" | "char_literal" => return,
_ => {}
}
if is_identifier_node(node.kind()) {
let text = node_text(node, source);
// Skip single-char identifiers and keywords
if text.len() > 1 {
refs.push(IdentRef {
name: text.to_string(),
line: node.start_position().row + 1,
import_path: None, // resolved later
});
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_ident_refs(child, source, refs);
}
}
fn is_identifier_node(kind: &str) -> bool {
matches!(
kind,
"identifier"
| "type_identifier"
| "field_identifier"
| "property_identifier"
| "shorthand_field_identifier"
)
}
/// Collect `use` declarations from Rust source.
/// Handles: `use foo::bar::Baz;`, `use foo::bar::{Baz, Qux};`, `use foo::bar as Alias;`
fn collect_rust_imports(node: Node, source: &str, imports: &mut Vec<ImportEntry>, file_end: usize) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"use_declaration" => {
let scope_end = find_scope_end(child, file_end);
let text = node_text(child, source);
parse_rust_use(text, child.start_position().row + 1, scope_end, imports);
}
// Recurse into blocks/functions to find scoped imports
"function_item" | "block" | "impl_item" | "mod_item" => {
let block_end = child.end_position().row + 1;
collect_rust_imports_scoped(child, source, imports, block_end);
}
_ => {}
}
}
}
fn collect_rust_imports_scoped(
node: Node,
source: &str,
imports: &mut Vec<ImportEntry>,
scope_end: usize,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"use_declaration" => {
let text = node_text(child, source);
parse_rust_use(text, child.start_position().row + 1, scope_end, imports);
}
"block" | "declaration_list" => {
collect_rust_imports_scoped(child, source, imports, scope_end);
}
_ => {}
}
}
}
/// Parse a Rust `use` statement text into ImportEntry items.
fn parse_rust_use(text: &str, line: usize, scope_end: usize, imports: &mut Vec<ImportEntry>) {
// Strip `use ` prefix and `;` suffix
let text = text.trim();
let text = text.strip_prefix("use ").unwrap_or(text);
let text = text.strip_suffix(';').unwrap_or(text).trim();
// Strip visibility (pub, pub(crate), etc.)
let text = if text.starts_with("pub") {
if let Some(rest) = text.strip_prefix("pub(") {
// pub(crate) use ..., pub(super) use ...
if let Some(after) = rest.find(')') {
rest[after + 1..].trim()
} else {
text
}
} else {
text.strip_prefix("pub ").unwrap_or(text).trim()
}
} else {
text
};
// Handle `use foo::bar::{A, B, C};`
if let Some(brace_start) = text.find('{') {
let prefix = &text[..brace_start];
let brace_end = text.rfind('}').unwrap_or(text.len());
let inner = &text[brace_start + 1..brace_end];
for item in inner.split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
// Handle `Foo as Bar`
let (orig, alias) = if let Some(as_pos) = item.find(" as ") {
(&item[..as_pos], &item[as_pos + 4..])
} else {
(item, item)
};
let alias = alias.trim();
let full = format!("{}{}", prefix, orig.trim());
if !alias.is_empty() && alias != "self" && alias != "*" {
imports.push(ImportEntry {
name: alias.to_string(),
full_path: full,
line,
scope_end,
});
}
}
} else {
// Simple: `use foo::bar::Baz;` or `use foo::bar::Baz as Alias;`
let (path, alias) = if let Some(as_pos) = text.find(" as ") {
(&text[..as_pos], &text[as_pos + 4..])
} else {
let name = text.rsplit("::").next().unwrap_or(text);
(text, name)
};
let alias = alias.trim();
if !alias.is_empty() && alias != "self" && alias != "*" {
imports.push(ImportEntry {
name: alias.to_string(),
full_path: path.trim().to_string(),
line,
scope_end,
});
}
}
}
/// Collect `import` declarations from TypeScript/Svelte.
fn collect_ts_imports(node: Node, source: &str, imports: &mut Vec<ImportEntry>, file_end: usize) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "import_statement" {
let text = node_text(child, source);
parse_ts_import(text, child.start_position().row + 1, file_end, imports);
}
}
}
/// Parse a TS `import { A, B } from 'module'` into ImportEntry items.
fn parse_ts_import(text: &str, line: usize, scope_end: usize, imports: &mut Vec<ImportEntry>) {
// Extract module path from `from '...'` or `from "..."`
let module = if let Some(from_pos) = text.find("from ") {
let rest = &text[from_pos + 5..];
rest.trim()
.trim_matches(|c| c == '\'' || c == '"' || c == ';')
.to_string()
} else {
return;
};
// Extract imported names from `{ A, B as C }`
if let Some(brace_start) = text.find('{') {
let brace_end = text.find('}').unwrap_or(text.len());
let inner = &text[brace_start + 1..brace_end];
for item in inner.split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
let (orig, alias) = if let Some(as_pos) = item.find(" as ") {
(&item[..as_pos], &item[as_pos + 4..])
} else {
(item, item)
};
let alias = alias.trim();
if !alias.is_empty() {
imports.push(ImportEntry {
name: alias.to_string(),
full_path: format!("{}.{}", module, orig.trim()),
line,
scope_end,
});
}
}
}
// Default import: `import Foo from '...'`
else {
let text_trimmed = text.trim().strip_prefix("import ").unwrap_or("");
if let Some(name_end) = text_trimmed.find(|c: char| c.is_whitespace()) {
let name = &text_trimmed[..name_end];
if !name.is_empty() && name != "type" && !name.starts_with('{') {
imports.push(ImportEntry {
name: name.to_string(),
full_path: format!("{}.default", module),
line,
scope_end,
});
}
}
}
}
/// Find the end line of the enclosing scope for a node.
fn find_scope_end(node: Node, file_end: usize) -> usize {
let mut parent = node.parent();
while let Some(p) = parent {
match p.kind() {
"block" | "declaration_list" | "function_item" => {
return p.end_position().row + 1;
}
_ => parent = p.parent(),
}
}
file_end
}
/// Resolve import paths for identifier refs.
fn resolve_refs(refs: &mut [IdentRef], imports: &[ImportEntry]) {
for r in refs.iter_mut() {
// Find the best matching import: same name, declared before the ref, ref within scope
let best = imports
.iter()
.filter(|imp| imp.name == r.name && imp.line <= r.line && r.line <= imp.scope_end)
.last(); // last = most recently declared (innermost scope)
if let Some(imp) = best {
r.import_path = Some(imp.full_path.clone());
}
}
}