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 # Check if the file is in the backend directory and is a Rust file
if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then
cd "$CLAUDE_PROJECT_DIR/backend" || exit 0 cd "$CLAUDE_PROJECT_DIR/backend" || exit 0
# Run rustfmt with config from rustfmt.toml (edition=2021) # Run rustfmt, surface errors as context but don't block Claude
rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>/dev/null || true if rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>&1; then
echo "Formatted $(basename "$FILE_PATH")"
fi
fi fi
exit 0 exit 0

View File

@@ -15,8 +15,10 @@ if [[ "$FILE_PATH" == *"/frontend/"* ]]; then
# Check if it's a formattable file type # Check if it's a formattable file type
if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then
cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0 cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0
# Run prettier silently, don't fail the hook if prettier fails # Run prettier, surface errors as context but don't block Claude
npx prettier --write "$FILE_PATH" 2>/dev/null || true if ./node_modules/.bin/prettier --plugin prettier-plugin-svelte --write "$FILE_PATH" 2>&1; then
echo "Formatted $(basename "$FILE_PATH")"
fi
fi fi
fi fi

View File

@@ -28,6 +28,12 @@
"Bash(git show:*)", "Bash(git show:*)",
"Bash(git blame:*)", "Bash(git blame:*)",
"Bash(cargo check:*)", "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", "mcp__ide__getDiagnostics",
"Bash(npm run generate-backend-client:*)", "Bash(npm run generate-backend-client:*)",
"Bash(npm run check:*)", "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 - Keep under 70 characters
- Use lowercase, imperative mood - Use lowercase, imperative mood
- No period at the end - No period at the end
- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] <type>: <description>`
## PR Body Format ## PR Body Format
@@ -85,3 +86,25 @@ Generated with [Claude Code](https://claude.com/claude-code)
)" )"
``` ```
7. Return the PR URL to the user 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: copy:
- backend/.env - backend/.env
- scripts/ - scripts/
- wm-ts-nav/target/release/wm-ts-nav
sandbox: sandbox:
enabled: false enabled: false

View File

@@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background
## Workflow ## 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 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`) 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` 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 - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Backend patterns**: use the `rust-backend` skill when writing Rust code - **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. - **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` - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **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. 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 ## 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 - Search for existing code to reuse before writing new code
- Follow established patterns in the codebase - Follow established patterns in the codebase
- Keep changes focused — don't refactor beyond what's asked - 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. 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 ## 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. 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. 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 ## Login
Default credentials: `admin@windmill.dev` / `changeme` Default credentials: `admin@windmill.dev` / `changeme`

View File

@@ -15,17 +15,22 @@
- Standard location: `~/windmill-ee-private` - Standard location: `~/windmill-ee-private`
- Worktree location: `~/windmill-ee-private__worktrees/<branch-name>/` - 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) ## EE PR Workflow (MUST DO when modifying `*_ee.rs` files)
When you modify any `*_ee.rs` file and create a PR on windmill: 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) 1. **Prefix the windmill PR title** with `[ee]`: `[ee] <type>: <description>`
2. **Commit and push** the `_ee.rs` changes in that branch 2. **Create a matching branch** in `windmill-ee-private` (same branch name)
3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR 3. **Commit and push** the `_ee.rs` changes in that branch
4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` 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) - **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 - 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 ## 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 "CLI deps installed and client generated" \
|| echo "WARNING: CLI setup failed" >&2 || echo "WARNING: CLI setup failed" >&2
fi 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() { wm_allow_direnv() {
@@ -261,5 +268,4 @@ wm_shared_pre_remove() {
fi fi
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());
}
}
}