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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:*)",
|
||||||
|
|||||||
98
.claude/skills/local-review/SKILL.md
Normal file
98
.claude/skills/local-review/SKILL.md
Normal 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="[...]"
|
||||||
|
```
|
||||||
@@ -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 1–5 from the "EE PR Workflow" in `docs/enterprise.md`
|
||||||
|
4. Create the companion PR (title does NOT get the `[ee]` prefix):
|
||||||
|
```bash
|
||||||
|
gh pr create --draft --repo windmill-labs/windmill-ee-private --title "<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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
38
CLAUDE.md
38
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
620
wm-ts-nav/Cargo.lock
generated
Normal 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
21
wm-ts-nav/Cargo.toml
Normal 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
10
wm-ts-nav/nav
Executable 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
422
wm-ts-nav/src/db.rs
Normal 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
102
wm-ts-nav/src/indexer.rs
Normal 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
270
wm-ts-nav/src/main.rs
Normal 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
716
wm-ts-nav/src/parser.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user