From 922c658740d51873ff3cf49609c7e41ea1bdc544 Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sat, 7 Feb 2026 12:17:23 -0500 Subject: [PATCH 1/7] Add AGENTS.md --- .beads/.gitignore | 48 +++++++++++++++++++++++ .beads/.jsonl.lock | 0 .beads/README.md | 81 +++++++++++++++++++++++++++++++++++++++ .beads/config.yaml | 67 ++++++++++++++++++++++++++++++++ .beads/interactions.jsonl | 0 .beads/issues.jsonl | 0 .beads/metadata.json | 4 ++ .gitattributes | 3 ++ AGENTS.md | 60 +++++++++++++++++++++++++++++ 9 files changed, 263 insertions(+) create mode 100644 .beads/.gitignore create mode 100644 .beads/.jsonl.lock create mode 100644 .beads/README.md create mode 100644 .beads/config.yaml create mode 100644 .beads/interactions.jsonl create mode 100644 .beads/issues.jsonl create mode 100644 .beads/metadata.json create mode 100644 .gitattributes create mode 100644 AGENTS.md diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 00000000..5ff56fcb --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,48 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl +export-state/ + +# Process semaphore slot files (runtime concurrency limiting) +sem/ + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/.jsonl.lock b/.beads/.jsonl.lock new file mode 100644 index 00000000..e69de29b diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 00000000..50f281f0 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 00000000..ff8bc921 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,67 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 00000000..c787975e --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..807d5983 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a6a9b76c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Summary +This project (mcp-language-server) is an MCP server that exposes language server protocol to AI agents. It helps MCP enabled clients (agents) navigate codebases more easily by giving them access semantic tools like get definition, references, rename, and diagnostics. + +The project is mature and almost feature-complete, but we will be making some modifications to it. + +We will use Beads (bd) for issue tracking. + +# Build +go build -o mcp-language-server + +# Install locally +go install + +# Format code +gofmt -w . + +# Generate LSP types and methods +go run ./cmd/generate + +# Run code audit checks + gofmt -l . + test -z "$(gofmt -l .)" + go tool staticcheck ./... + go tool errcheck ./... + find . -path "./integrationtests/workspaces" -prune -o \ + -path "./integrationtests/test-output" -prune -o \ + -name "*.go" -print | xargs gopls check + go tool govulncheck ./... + +# Run tests +go test ./... + +# Update snapshot tests +UPDATE_SNAPSHOTS=true go test ./integrationtests/... + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds From 5d311ccffe4c9c083fb8e7b16761aea08ce988aa Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sat, 7 Feb 2026 12:30:54 -0500 Subject: [PATCH 2/7] bead --- .beads/.gitignore | 48 ----------------------- .beads/.jsonl.lock | 0 .beads/README.md | 81 --------------------------------------- .beads/config.yaml | 67 -------------------------------- .beads/interactions.jsonl | 0 .beads/issues.jsonl | 0 .beads/metadata.json | 4 -- .gitattributes | 3 -- 8 files changed, 203 deletions(-) delete mode 100644 .beads/.gitignore delete mode 100644 .beads/.jsonl.lock delete mode 100644 .beads/README.md delete mode 100644 .beads/config.yaml delete mode 100644 .beads/interactions.jsonl delete mode 100644 .beads/issues.jsonl delete mode 100644 .beads/metadata.json delete mode 100644 .gitattributes diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index 5ff56fcb..00000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,48 +0,0 @@ -# SQLite databases -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm - -# Daemon runtime files -daemon.lock -daemon.log -daemon.pid -bd.sock -sync-state.json -last-touched - -# Local version tracking (prevents upgrade notification spam after git ops) -.local_version - -# Legacy database files -db.sqlite -bd.db - -# Worktree redirect file (contains relative path to main repo's .beads/) -# Must not be committed as paths would be wrong in other clones -redirect - -# Merge artifacts (temporary files from 3-way merge) -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - -# Sync state (local-only, per-machine) -# These files are machine-specific and should not be shared across clones -.sync.lock -sync_base.jsonl -export-state/ - -# Process semaphore slot files (runtime concurrency limiting) -sem/ - -# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. -# They would override fork protection in .git/info/exclude, allowing -# contributors to accidentally commit upstream issue databases. -# The JSONL files (issues.jsonl, interactions.jsonl) and config files -# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/.jsonl.lock b/.beads/.jsonl.lock deleted file mode 100644 index e69de29b..00000000 diff --git a/.beads/README.md b/.beads/README.md deleted file mode 100644 index 50f281f0..00000000 --- a/.beads/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Beads - AI-Native Issue Tracking - -Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. - -## What is Beads? - -Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. - -**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) - -## Quick Start - -### Essential Commands - -```bash -# Create new issues -bd create "Add user authentication" - -# View all issues -bd list - -# View issue details -bd show - -# Update issue status -bd update --status in_progress -bd update --status done - -# Sync with git remote -bd sync -``` - -### Working with Issues - -Issues in Beads are: -- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code -- **AI-friendly**: CLI-first design works perfectly with AI coding agents -- **Branch-aware**: Issues can follow your branch workflow -- **Always in sync**: Auto-syncs with your commits - -## Why Beads? - -✨ **AI-Native Design** -- Built specifically for AI-assisted development workflows -- CLI-first interface works seamlessly with AI coding agents -- No context switching to web UIs - -🚀 **Developer Focused** -- Issues live in your repo, right next to your code -- Works offline, syncs when you push -- Fast, lightweight, and stays out of your way - -🔧 **Git Integration** -- Automatic sync with git commits -- Branch-aware issue tracking -- Intelligent JSONL merge resolution - -## Get Started with Beads - -Try Beads in your own projects: - -```bash -# Install Beads -curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash - -# Initialize in your repo -bd init - -# Create your first issue -bd create "Try out Beads" -``` - -## Learn More - -- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) -- **Quick Start Guide**: Run `bd quickstart` -- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) - ---- - -*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml deleted file mode 100644 index ff8bc921..00000000 --- a/.beads/config.yaml +++ /dev/null @@ -1,67 +0,0 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags - -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" - -# Use no-db mode: load from JSONL, no SQLite, write back after each command -# When true, bd will use .beads/issues.jsonl as the source of truth -# instead of SQLite database -# no-db: false - -# Disable daemon for RPC communication (forces direct database access) -# no-daemon: false - -# Disable auto-flush of database to JSONL after mutations -# no-auto-flush: false - -# Disable auto-import from JSONL when it's newer than database -# no-auto-import: false - -# Enable JSON output by default -# json: false - -# Default actor for audit trails (overridden by BD_ACTOR or --actor) -# actor: "" - -# Path to database (overridden by BEADS_DB or --db) -# db: "" - -# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) -# auto-start-daemon: true - -# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) -# flush-debounce: "5s" - -# Export events (audit trail) to .beads/events.jsonl on each flush/sync -# When enabled, new events are appended incrementally using a high-water mark. -# Use 'bd export --events' to trigger manually regardless of this setting. -# events-export: false - -# Git branch for beads commits (bd sync will commit to this branch) -# IMPORTANT: Set this for team projects so all clones use the same sync branch. -# This setting persists across clones (unlike database config which is gitignored). -# Can also use BEADS_SYNC_BRANCH env var for local override. -# If not set, bd sync will require you to run 'bd config set sync.branch '. -# sync-branch: "beads-sync" - -# Multi-repo configuration (experimental - bd-307) -# Allows hydrating from multiple repositories and routing writes to the correct JSONL -# repos: -# primary: "." # Primary repo (where this database lives) -# additional: # Additional repos to hydrate from (read-only) -# - ~/beads-planning # Personal planning repo -# - ~/work-planning # Work planning repo - -# Integration settings (access with 'bd config get/set') -# These are stored in the database, not in this file: -# - jira.url -# - jira.project -# - linear.url -# - linear.api-key -# - github.org -# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl deleted file mode 100644 index e69de29b..00000000 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl deleted file mode 100644 index e69de29b..00000000 diff --git a/.beads/metadata.json b/.beads/metadata.json deleted file mode 100644 index c787975e..00000000 --- a/.beads/metadata.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "database": "beads.db", - "jsonl_export": "issues.jsonl" -} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 807d5983..00000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ - -# Use bd merge for beads JSONL files -.beads/issues.jsonl merge=beads From 9d896114a9e321ca2860aefdee8394b35e08fd33 Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sat, 7 Feb 2026 15:05:45 -0500 Subject: [PATCH 3/7] add -lsp-connect feature --- integrationtests/tests/common/framework.go | 58 +++++++----- .../go/definition/definition_headless_test.go | 52 +++++++++++ integrationtests/tests/go/internal/helpers.go | 41 ++++++--- .../go/references/references_headless_test.go | 53 +++++++++++ internal/lsp/client.go | 62 ++++++++++--- internal/watcher/watcher.go | 9 ++ main.go | 88 ++++++++++++------- 7 files changed, 282 insertions(+), 81 deletions(-) create mode 100644 integrationtests/tests/go/definition/definition_headless_test.go create mode 100644 integrationtests/tests/go/references/references_headless_test.go diff --git a/integrationtests/tests/common/framework.go b/integrationtests/tests/common/framework.go index 0d6eb1fa..e703fd0a 100644 --- a/integrationtests/tests/common/framework.go +++ b/integrationtests/tests/common/framework.go @@ -19,8 +19,9 @@ import ( // LSPTestConfig defines configuration for a language server test type LSPTestConfig struct { Name string // Name of the language server - Command string // Command to run - Args []string // Arguments + Command string // Command to run (ignored if ConnectAddr is set) + Args []string // Arguments (ignored if ConnectAddr is set) + ConnectAddr string // If set, connect to existing LSP at this address (headless) instead of starting Command WorkspaceDir string // Template workspace directory InitializeTimeMs int // Time to wait after initialization in ms } @@ -39,6 +40,7 @@ type TestSuite struct { logFile string t *testing.T LanguageName string + headless bool // true when using ConnectAddr (skip shutdown/exit on cleanup) } // NewTestSuite creates a new test suite for the given language server @@ -156,12 +158,25 @@ func (ts *TestSuite) Setup() error { ts.t.Logf("Copied workspace from %s to %s", ts.Config.WorkspaceDir, workspaceDir) // Create and initialize LSP client - client, err := lsp.NewClient(ts.Config.Command, ts.Config.Args...) - if err != nil { - return fmt.Errorf("failed to create LSP client: %w", err) + var client *lsp.Client + if ts.Config.ConnectAddr != "" { + ts.headless = true + var err error + client, err = lsp.NewClientHeadless(ts.Config.ConnectAddr) + if err != nil { + return fmt.Errorf("failed to connect to LSP at %s: %w", ts.Config.ConnectAddr, err) + } + ts.Client = client + ts.t.Logf("Connected to LSP at %s (headless)", ts.Config.ConnectAddr) + } else { + var err error + client, err = lsp.NewClient(ts.Config.Command, ts.Config.Args...) + if err != nil { + return fmt.Errorf("failed to create LSP client: %w", err) + } + ts.Client = client + ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) } - ts.Client = client - ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) // Initialize LSP and set up file watcher initResult, err := client.InitializeLSPClient(ts.Context, workspaceDir) @@ -197,24 +212,21 @@ func (ts *TestSuite) Cleanup() { // Cancel context to stop watchers ts.Cancel() - // Shutdown LSP + // Shutdown LSP (headless: only close connection; subprocess: shutdown + exit + close) if ts.Client != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - ts.t.Logf("Shutting down LSP client") - err := ts.Client.Shutdown(shutdownCtx) - if err != nil { - ts.t.Logf("Shutdown failed: %v", err) + if !ts.headless { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ts.t.Logf("Shutting down LSP client") + if err := ts.Client.Shutdown(shutdownCtx); err != nil { + ts.t.Logf("Shutdown failed: %v", err) + } + if err := ts.Client.Exit(shutdownCtx); err != nil { + ts.t.Logf("Exit failed: %v", err) + } } - - err = ts.Client.Exit(shutdownCtx) - if err != nil { - ts.t.Logf("Exit failed: %v", err) - } - - err = ts.Client.Close() - if err != nil { + if err := ts.Client.Close(); err != nil { ts.t.Logf("Close failed: %v", err) } } diff --git a/integrationtests/tests/go/definition/definition_headless_test.go b/integrationtests/tests/go/definition/definition_headless_test.go new file mode 100644 index 00000000..48293c63 --- /dev/null +++ b/integrationtests/tests/go/definition/definition_headless_test.go @@ -0,0 +1,52 @@ +package definition_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" + "github.com/isaacphi/mcp-language-server/integrationtests/tests/go/internal" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestReadDefinitionHeadless runs ReadDefinition against an already-running gopls. +// Requires GOPLS_HEADLESS_ADDR (e.g. localhost:6060). The server must be started separately (e.g. gopls -listen=:6060). +func TestReadDefinitionHeadless(t *testing.T) { + suite := internal.GetHeadlessTestSuite(t) + + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + tests := []struct { + name string + symbolName string + expectedText string + snapshotName string + }{ + {"Function", "FooBar", "func FooBar()", "foobar"}, + {"Struct", "TestStruct", "type TestStruct struct", "struct"}, + {"Method", "TestStruct.Method", "func (t *TestStruct) Method()", "method"}, + {"Interface", "TestInterface", "type TestInterface interface", "interface"}, + {"Type", "TestType", "type TestType string", "type"}, + {"Constant", "TestConstant", "const TestConstant", "constant"}, + {"Variable", "TestVariable", "var TestVariable", "variable"}, + {"TestFunction", "TestFunction", "func TestFunction()", "function"}, + {"NotFound", "NotFound", "not found", "not-found"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) + if err != nil { + t.Fatalf("ReadDefinition failed: %v", err) + } + // Headless server may have a different workspace; only assert NotFound case + if tc.snapshotName == "not-found" && !strings.Contains(result, "not found") { + t.Errorf("expected 'not found' in result for unknown symbol, got: %s", result) + } + common.SnapshotTest(t, "go", "definition_headless", tc.snapshotName, result) + }) + } +} diff --git a/integrationtests/tests/go/internal/helpers.go b/integrationtests/tests/go/internal/helpers.go index 4551fe87..7e1dc8f9 100644 --- a/integrationtests/tests/go/internal/helpers.go +++ b/integrationtests/tests/go/internal/helpers.go @@ -2,15 +2,15 @@ package internal import ( + "os" "path/filepath" "testing" "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" ) -// GetTestSuite returns a test suite for Go language server tests +// GetTestSuite returns a test suite for Go language server tests (starts gopls as subprocess) func GetTestSuite(t *testing.T) *common.TestSuite { - // Configure Go LSP repoRoot, err := filepath.Abs("../../../..") if err != nil { t.Fatalf("Failed to get repo root: %v", err) @@ -21,22 +21,41 @@ func GetTestSuite(t *testing.T) *common.TestSuite { Command: "gopls", Args: []string{}, WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), - InitializeTimeMs: 2000, // 2 seconds + InitializeTimeMs: 2000, } - // Create a test suite suite := common.NewTestSuite(t, config) + if err := suite.Setup(); err != nil { + t.Fatalf("Failed to set up test suite: %v", err) + } + t.Cleanup(func() { suite.Cleanup() }) + return suite +} - // Set up the suite - err = suite.Setup() +// GetHeadlessTestSuite returns a test suite that connects to an existing gopls at GOPLS_HEADLESS_ADDR. +// Skips the test if GOPLS_HEADLESS_ADDR is not set. The server must be started separately (e.g. gopls -listen=:6060). +func GetHeadlessTestSuite(t *testing.T) *common.TestSuite { + addr := os.Getenv("GOPLS_HEADLESS_ADDR") + if addr == "" { + t.Skip("GOPLS_HEADLESS_ADDR not set; set to e.g. localhost:6060 to run headless tests (gopls must be running with -listen=:6060)") + } + + repoRoot, err := filepath.Abs("../../../..") if err != nil { - t.Fatalf("Failed to set up test suite: %v", err) + t.Fatalf("Failed to get repo root: %v", err) } - // Register cleanup - t.Cleanup(func() { - suite.Cleanup() - }) + config := common.LSPTestConfig{ + Name: "go", + ConnectAddr: addr, + WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), + InitializeTimeMs: 2000, + } + suite := common.NewTestSuite(t, config) + if err := suite.Setup(); err != nil { + t.Fatalf("Failed to set up headless test suite: %v", err) + } + t.Cleanup(func() { suite.Cleanup() }) return suite } diff --git a/integrationtests/tests/go/references/references_headless_test.go b/integrationtests/tests/go/references/references_headless_test.go new file mode 100644 index 00000000..c63accb3 --- /dev/null +++ b/integrationtests/tests/go/references/references_headless_test.go @@ -0,0 +1,53 @@ +package references_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" + "github.com/isaacphi/mcp-language-server/integrationtests/tests/go/internal" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestFindReferencesHeadless runs FindReferences against an already-running gopls. +// Requires GOPLS_HEADLESS_ADDR (e.g. localhost:6060). The server must be started separately (e.g. gopls -listen=:6060). +func TestFindReferencesHeadless(t *testing.T) { + suite := internal.GetHeadlessTestSuite(t) + + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + tests := []struct { + name string + symbolName string + expectedText string + expectedFiles int + snapshotName string + }{ + {"Function across files", "HelperFunction", "ConsumerFunction", 2, "helper-function"}, + {"Function same file", "FooBar", "main()", 1, "foobar-function"}, + {"Struct across files", "SharedStruct", "ConsumerFunction", 2, "shared-struct"}, + {"Method", "SharedStruct.Method", "s.Method()", 1, "struct-method"}, + {"Interface", "SharedInterface", "var iface SharedInterface", 2, "shared-interface"}, + {"Interface method", "SharedInterface.GetName", "iface.GetName()", 1, "interface-method"}, + {"Constant", "SharedConstant", "SharedConstant", 2, "shared-constant"}, + {"Type", "SharedType", "SharedType", 2, "shared-type"}, + {"NotFound", "NotFound", "No references found for symbol:", 0, "not-found"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) + if err != nil { + t.Fatalf("FindReferences failed: %v", err) + } + // Headless server may have a different workspace; only assert NotFound case + if tc.snapshotName == "not-found" && !strings.Contains(result, "No references found") { + t.Errorf("expected 'No references found' for unknown symbol, got: %s", result) + } + common.SnapshotTest(t, "go", "references_headless", tc.snapshotName, result) + }) + } +} diff --git a/internal/lsp/client.go b/internal/lsp/client.go index fc07059d..57472a8e 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net" "os" "os/exec" "strings" @@ -101,6 +102,33 @@ func NewClient(command string, args ...string) (*Client, error) { return client, nil } +// NewClientHeadless connects to an already-running LSP server at addr (e.g. "localhost:6060"). +// The server must speak LSP JSON-RPC over the stream (Content-Length + JSON). No process is +// started and stderr is not read. For gopls, start the server with -listen=:6060 so it +// accepts LSP connections on that port (--debug is for the debug HTTP server, not LSP). +func NewClientHeadless(addr string) (*Client, error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to LSP at %s: %w", addr, err) + } + + client := &Client{ + Cmd: nil, + stdin: conn, + stdout: bufio.NewReader(conn), + stderr: nil, + handlers: make(map[string]chan *Message), + notificationHandlers: make(map[string]NotificationHandler), + serverRequestHandlers: make(map[string]ServerRequestHandler), + diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic), + openFiles: make(map[string]*OpenFileInfo), + } + + go client.handleMessages() + + return client, nil +} + func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) { c.notificationMu.Lock() defer c.notificationMu.Unlock() @@ -214,13 +242,15 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) ( return nil, fmt.Errorf("initialization failed: %w", err) } - // LSP sepecific Initialization - path := strings.ToLower(c.Cmd.Path) - switch { - case strings.Contains(path, "typescript-language-server"): - err := initializeTypescriptLanguageServer(ctx, c, workspaceDir) - if err != nil { - return nil, err + // LSP-specific initialization (only when we started the process; headless client has no Cmd) + if c.Cmd != nil { + path := strings.ToLower(c.Cmd.Path) + switch { + case strings.Contains(path, "typescript-language-server"): + err := initializeTypescriptLanguageServer(ctx, c, workspaceDir) + if err != nil { + return nil, err + } } } @@ -228,14 +258,21 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) ( } func (c *Client) Close() error { - // Try to close all open files first ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Attempt to close files but continue shutdown regardless c.CloseAllFiles(ctx) - // Force kill the LSP process if it doesn't exit within timeout + if c.Cmd == nil { + // Headless mode: only close the connection; do not send shutdown/exit or kill any process + if err := c.stdin.Close(); err != nil { + lspLogger.Error("Failed to close connection: %v", err) + return err + } + return nil + } + + // Subprocess mode: close stdin then wait for process (with optional force kill) forcedKill := make(chan struct{}) go func() { select { @@ -250,19 +287,16 @@ func (c *Client) Close() error { } close(forcedKill) case <-forcedKill: - // Channel closed from completion path return } }() - // Close stdin to signal the server if err := c.stdin.Close(); err != nil { lspLogger.Error("Failed to close stdin: %v", err) } - // Wait for process to exit err := c.Cmd.Wait() - close(forcedKill) // Stop the force kill goroutine + close(forcedKill) return err } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 6e7a0b8a..73187a1f 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -121,6 +121,10 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc if err != nil { return err } + // Abort scan when context is cancelled so the watcher can exit and the process can terminate + if ctx.Err() != nil { + return ctx.Err() + } // Skip directories that should be excluded if d.IsDir() { @@ -157,6 +161,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) { w.workspacePath = workspacePath + // Unregister the file watch handler when this watcher exits so the LSP + // package does not call into a stopped watcher (avoids hangs/panics in tests + // and when running headless without a watcher). + defer lsp.RegisterFileWatchHandler(nil) + // Initialize gitignore matcher gitignore, err := NewGitignoreMatcher(workspacePath) if err != nil { diff --git a/main.go b/main.go index f6f3ed5c..51b75d20 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ type config struct { workspaceDir string lspCommand string lspArgs []string + lspConnect string // if set, connect to existing LSP at this address (e.g. localhost:6060) instead of starting a process } type mcpServer struct { @@ -33,12 +34,14 @@ type mcpServer struct { ctx context.Context cancelFunc context.CancelFunc workspaceWatcher *watcher.WorkspaceWatcher + headless bool // true when using -lsp-connect (do not send shutdown/exit on cleanup) } func parseConfig() (*config, error) { cfg := &config{} flag.StringVar(&cfg.workspaceDir, "workspace", "", "Path to workspace directory") flag.StringVar(&cfg.lspCommand, "lsp", "", "LSP command to run (args should be passed after --)") + flag.StringVar(&cfg.lspConnect, "lsp-connect", "", "Connect to existing LSP at address (e.g. localhost:6060) instead of starting a process (for gopls use -listen=:PORT)") flag.Parse() // Get remaining args after -- as LSP arguments @@ -59,13 +62,17 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("workspace directory does not exist: %s", cfg.workspaceDir) } - // Validate LSP command - if cfg.lspCommand == "" { - return nil, fmt.Errorf("LSP command is required") + // Either lsp-connect or lsp command is required + if cfg.lspConnect == "" && cfg.lspCommand == "" { + return nil, fmt.Errorf("either -lsp-connect or -lsp is required") } - - if _, err := exec.LookPath(cfg.lspCommand); err != nil { - return nil, fmt.Errorf("LSP command not found: %s", cfg.lspCommand) + if cfg.lspConnect != "" && cfg.lspCommand != "" { + return nil, fmt.Errorf("cannot use both -lsp-connect and -lsp") + } + if cfg.lspCommand != "" { + if _, err := exec.LookPath(cfg.lspCommand); err != nil { + return nil, fmt.Errorf("LSP command not found: %s", cfg.lspCommand) + } } return cfg, nil @@ -85,12 +92,26 @@ func (s *mcpServer) initializeLSP() error { return fmt.Errorf("failed to change to workspace directory: %v", err) } - client, err := lsp.NewClient(s.config.lspCommand, s.config.lspArgs...) - if err != nil { - return fmt.Errorf("failed to create LSP client: %v", err) + var client *lsp.Client + var err error + if s.config.lspConnect != "" { + s.headless = true + client, err = lsp.NewClientHeadless(s.config.lspConnect) + if err != nil { + return fmt.Errorf("failed to connect to LSP at %s: %v", s.config.lspConnect, err) + } + } else { + client, err = lsp.NewClient(s.config.lspCommand, s.config.lspArgs...) + if err != nil { + return fmt.Errorf("failed to create LSP client: %v", err) + } } s.lspClient = client - s.workspaceWatcher = watcher.NewWorkspaceWatcher(client) + + // In headless mode, disable file watchers to avoid background scanning and fsnotify + if !s.headless { + s.workspaceWatcher = watcher.NewWorkspaceWatcher(client) + } initResult, err := client.InitializeLSPClient(s.ctx, s.config.workspaceDir) if err != nil { @@ -99,7 +120,9 @@ func (s *mcpServer) initializeLSP() error { coreLogger.Debug("Server capabilities: %+v", initResult.Capabilities) - go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.workspaceDir) + if s.workspaceWatcher != nil { + go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.workspaceDir) + } return client.WaitForServerReady(s.ctx) } @@ -201,31 +224,30 @@ func cleanup(s *mcpServer, done chan struct{}) { coreLogger.Info("Closing open files") s.lspClient.CloseAllFiles(ctx) - // Create a shorter timeout context for the shutdown request - shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) - defer shutdownCancel() + if !s.headless { + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer shutdownCancel() - // Run shutdown in a goroutine with timeout to avoid blocking if LSP doesn't respond - shutdownDone := make(chan struct{}) - go func() { - coreLogger.Info("Sending shutdown request") - if err := s.lspClient.Shutdown(shutdownCtx); err != nil { - coreLogger.Error("Shutdown request failed: %v", err) - } - close(shutdownDone) - }() + shutdownDone := make(chan struct{}) + go func() { + coreLogger.Info("Sending shutdown request") + if err := s.lspClient.Shutdown(shutdownCtx); err != nil { + coreLogger.Error("Shutdown request failed: %v", err) + } + close(shutdownDone) + }() - // Wait for shutdown with timeout - select { - case <-shutdownDone: - coreLogger.Info("Shutdown request completed") - case <-time.After(1 * time.Second): - coreLogger.Warn("Shutdown request timed out, proceeding with exit") - } + select { + case <-shutdownDone: + coreLogger.Info("Shutdown request completed") + case <-time.After(1 * time.Second): + coreLogger.Warn("Shutdown request timed out, proceeding with exit") + } - coreLogger.Info("Sending exit notification") - if err := s.lspClient.Exit(ctx); err != nil { - coreLogger.Error("Exit notification failed: %v", err) + coreLogger.Info("Sending exit notification") + if err := s.lspClient.Exit(ctx); err != nil { + coreLogger.Error("Exit notification failed: %v", err) + } } coreLogger.Info("Closing LSP client") From 2fbde3b32cc72843f9f394a3703bae59c4cf7514 Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sat, 7 Feb 2026 16:13:33 -0500 Subject: [PATCH 4/7] disable headless in integration tests --- AGENTS.md | 26 -------- .../go/definition_headless/constant.snap | 10 +++ .../go/definition_headless/foobar.snap | 14 ++++ .../go/definition_headless/function.snap | 12 ++++ .../go/definition_headless/interface.snap | 12 ++++ .../go/definition_headless/method.snap | 12 ++++ .../go/definition_headless/not-found.snap | 1 + .../go/definition_headless/struct.snap | 13 ++++ .../go/definition_headless/type.snap | 10 +++ .../go/definition_headless/variable.snap | 10 +++ .../snapshots/go/hover/struct-type.snap | 2 +- .../references_headless/foobar-function.snap | 9 +++ .../references_headless/helper-function.snap | 28 ++++++++ .../references_headless/interface-method.snap | 39 +++++++++++ .../go/references_headless/not-found.snap | 1 + .../references_headless/shared-constant.snap | 39 +++++++++++ .../references_headless/shared-interface.snap | 39 +++++++++++ .../go/references_headless/shared-struct.snap | 66 +++++++++++++++++++ .../go/references_headless/shared-type.snap | 35 ++++++++++ .../go/references_headless/struct-method.snap | 19 ++++++ integrationtests/tests/common/framework.go | 26 +++----- 21 files changed, 378 insertions(+), 45 deletions(-) create mode 100644 integrationtests/snapshots/go/definition_headless/constant.snap create mode 100644 integrationtests/snapshots/go/definition_headless/foobar.snap create mode 100644 integrationtests/snapshots/go/definition_headless/function.snap create mode 100644 integrationtests/snapshots/go/definition_headless/interface.snap create mode 100644 integrationtests/snapshots/go/definition_headless/method.snap create mode 100644 integrationtests/snapshots/go/definition_headless/not-found.snap create mode 100644 integrationtests/snapshots/go/definition_headless/struct.snap create mode 100644 integrationtests/snapshots/go/definition_headless/type.snap create mode 100644 integrationtests/snapshots/go/definition_headless/variable.snap create mode 100644 integrationtests/snapshots/go/references_headless/foobar-function.snap create mode 100644 integrationtests/snapshots/go/references_headless/helper-function.snap create mode 100644 integrationtests/snapshots/go/references_headless/interface-method.snap create mode 100644 integrationtests/snapshots/go/references_headless/not-found.snap create mode 100644 integrationtests/snapshots/go/references_headless/shared-constant.snap create mode 100644 integrationtests/snapshots/go/references_headless/shared-interface.snap create mode 100644 integrationtests/snapshots/go/references_headless/shared-struct.snap create mode 100644 integrationtests/snapshots/go/references_headless/shared-type.snap create mode 100644 integrationtests/snapshots/go/references_headless/struct-method.snap diff --git a/AGENTS.md b/AGENTS.md index a6a9b76c..53dc516a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,29 +32,3 @@ go test ./... # Update snapshot tests UPDATE_SNAPSHOTS=true go test ./integrationtests/... - -## Landing the Plane (Session Completion) - -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. - -**MANDATORY WORKFLOW:** - -1. **File issues for remaining work** - Create issues for anything that needs follow-up -2. **Run quality gates** (if code changed) - Tests, linters, builds -3. **Update issue status** - Close finished work, update in-progress items -4. **PUSH TO REMOTE** - This is MANDATORY: - ```bash - git pull --rebase - bd sync - git push - git status # MUST show "up to date with origin" - ``` -5. **Clean up** - Clear stashes, prune remote branches -6. **Verify** - All changes committed AND pushed -7. **Hand off** - Provide context for next session - -**CRITICAL RULES:** -- Work is NOT complete until `git push` succeeds -- NEVER stop before pushing - that leaves work stranded locally -- NEVER say "ready to push when you are" - YOU must push -- If push fails, resolve and retry until it succeeds diff --git a/integrationtests/snapshots/go/definition_headless/constant.snap b/integrationtests/snapshots/go/definition_headless/constant.snap new file mode 100644 index 00000000..35482d8d --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/constant.snap @@ -0,0 +1,10 @@ +--- + +Symbol: TestConstant +/TEST_OUTPUT/workspace/clean.go +Kind: Constant +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L25:C1 - L25:C38 + +25|const TestConstant = "constant value" + diff --git a/integrationtests/snapshots/go/definition_headless/foobar.snap b/integrationtests/snapshots/go/definition_headless/foobar.snap new file mode 100644 index 00000000..d75e774f --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/foobar.snap @@ -0,0 +1,14 @@ +--- + +Symbol: FooBar +/TEST_OUTPUT/workspace/main.go +Kind: Function +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L6:C1 - L10:C2 + + 6|func FooBar() string { + 7| return "Hello, World!" + 8| fmt.Println("Unreachable code") // This is unreachable code + 9| return 3 +10|} + diff --git a/integrationtests/snapshots/go/definition_headless/function.snap b/integrationtests/snapshots/go/definition_headless/function.snap new file mode 100644 index 00000000..e9b6b4af --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/function.snap @@ -0,0 +1,12 @@ +--- + +Symbol: TestFunction +/TEST_OUTPUT/workspace/clean.go +Kind: Function +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L31:C1 - L33:C2 + +31|func TestFunction() { +32| fmt.Println("This is a test function") +33|} + diff --git a/integrationtests/snapshots/go/definition_headless/interface.snap b/integrationtests/snapshots/go/definition_headless/interface.snap new file mode 100644 index 00000000..aaddc556 --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/interface.snap @@ -0,0 +1,12 @@ +--- + +Symbol: TestInterface +/TEST_OUTPUT/workspace/clean.go +Kind: Interface +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L17:C1 - L19:C2 + +17|type TestInterface interface { +18| DoSomething() error +19|} + diff --git a/integrationtests/snapshots/go/definition_headless/method.snap b/integrationtests/snapshots/go/definition_headless/method.snap new file mode 100644 index 00000000..7084befc --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/method.snap @@ -0,0 +1,12 @@ +--- + +Symbol: TestStruct.Method +/TEST_OUTPUT/workspace/clean.go +Kind: Method +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L12:C1 - L14:C2 + +12|func (t *TestStruct) Method() string { +13| return t.Name +14|} + diff --git a/integrationtests/snapshots/go/definition_headless/not-found.snap b/integrationtests/snapshots/go/definition_headless/not-found.snap new file mode 100644 index 00000000..1f0c9df0 --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/not-found.snap @@ -0,0 +1 @@ +NotFound not found \ No newline at end of file diff --git a/integrationtests/snapshots/go/definition_headless/struct.snap b/integrationtests/snapshots/go/definition_headless/struct.snap new file mode 100644 index 00000000..4fbb2e05 --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/struct.snap @@ -0,0 +1,13 @@ +--- + +Symbol: TestStruct +/TEST_OUTPUT/workspace/clean.go +Kind: Struct +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L6:C1 - L9:C2 + + 6|type TestStruct struct { + 7| Name string + 8| Age int + 9|} + diff --git a/integrationtests/snapshots/go/definition_headless/type.snap b/integrationtests/snapshots/go/definition_headless/type.snap new file mode 100644 index 00000000..66849d21 --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/type.snap @@ -0,0 +1,10 @@ +--- + +Symbol: TestType +/TEST_OUTPUT/workspace/clean.go +Kind: Class +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L22:C1 - L22:C21 + +22|type TestType string + diff --git a/integrationtests/snapshots/go/definition_headless/variable.snap b/integrationtests/snapshots/go/definition_headless/variable.snap new file mode 100644 index 00000000..030f41a1 --- /dev/null +++ b/integrationtests/snapshots/go/definition_headless/variable.snap @@ -0,0 +1,10 @@ +--- + +Symbol: TestVariable +/TEST_OUTPUT/workspace/clean.go +Kind: Variable +Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace +Range: L28:C1 - L28:C22 + +28|var TestVariable = 42 + diff --git a/integrationtests/snapshots/go/hover/struct-type.snap b/integrationtests/snapshots/go/hover/struct-type.snap index 08c7820a..34aa914f 100644 --- a/integrationtests/snapshots/go/hover/struct-type.snap +++ b/integrationtests/snapshots/go/hover/struct-type.snap @@ -1,5 +1,5 @@ ```go -type SharedStruct struct { // size=56 (0x38) +type SharedStruct struct { // size=56 (0x38), class=64 (0x40) ID int Name string Value float64 diff --git a/integrationtests/snapshots/go/references_headless/foobar-function.snap b/integrationtests/snapshots/go/references_headless/foobar-function.snap new file mode 100644 index 00000000..3ca23eed --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/foobar-function.snap @@ -0,0 +1,9 @@ +--- + +/TEST_OUTPUT/workspace/main.go +References in File: 1 +At: L13:C14 + +12|func main() { +13| fmt.Println(FooBar()) +14|} diff --git a/integrationtests/snapshots/go/references_headless/helper-function.snap b/integrationtests/snapshots/go/references_headless/helper-function.snap new file mode 100644 index 00000000..fe7fcb49 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/helper-function.snap @@ -0,0 +1,28 @@ +--- + +/TEST_OUTPUT/workspace/another_consumer.go +References in File: 1 +At: L8:C34 + + 6|func AnotherConsumer() { + 7| // Use helper function + 8| fmt.Println("Another message:", HelperFunction()) + 9| +10| // Create another SharedStruct instance +11| s := &SharedStruct{ +12| ID: 2, +13| Name: "another test", + +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L7:C13 + + 6|func ConsumerFunction() { + 7| message := HelperFunction() + 8| fmt.Println(message) + 9| +10| // Use shared struct +11| s := &SharedStruct{ +12| ID: 1, diff --git a/integrationtests/snapshots/go/references_headless/interface-method.snap b/integrationtests/snapshots/go/references_headless/interface-method.snap new file mode 100644 index 00000000..f8d54cc9 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/interface-method.snap @@ -0,0 +1,39 @@ +--- + +/TEST_OUTPUT/workspace/another_consumer.go +References in File: 1 +At: L19:C15 + +6|func AnotherConsumer() { +... +14| Value: 99.9, +15| Constants: []string{SharedConstant, "extra"}, +16| } +17| +18| // Use the struct methods +19| if name := s.GetName(); name != "" { +20| fmt.Println("Got name:", name) +21| } +22| +23| // Implement the interface with a custom type +24| type CustomImplementor struct { + +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L24:C20 + +6|func ConsumerFunction() { +... +19| fmt.Println(s.Method()) +20| s.Process() +21| +22| // Use shared interface +23| var iface SharedInterface = s +24| fmt.Println(iface.GetName()) +25| +26| // Use shared type +27| var t SharedType = 100 +28| fmt.Println(t) +29|} diff --git a/integrationtests/snapshots/go/references_headless/not-found.snap b/integrationtests/snapshots/go/references_headless/not-found.snap new file mode 100644 index 00000000..fa0a5532 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/not-found.snap @@ -0,0 +1 @@ +No references found for symbol: NotFound \ No newline at end of file diff --git a/integrationtests/snapshots/go/references_headless/shared-constant.snap b/integrationtests/snapshots/go/references_headless/shared-constant.snap new file mode 100644 index 00000000..a90fc9d6 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/shared-constant.snap @@ -0,0 +1,39 @@ +--- + +/TEST_OUTPUT/workspace/another_consumer.go +References in File: 1 +At: L15:C23 + +6|func AnotherConsumer() { +... +10| // Create another SharedStruct instance +11| s := &SharedStruct{ +12| ID: 2, +13| Name: "another test", +14| Value: 99.9, +15| Constants: []string{SharedConstant, "extra"}, +16| } +17| +18| // Use the struct methods +19| if name := s.GetName(); name != "" { +20| fmt.Println("Got name:", name) + +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L15:C23 + +6|func ConsumerFunction() { +... +10| // Use shared struct +11| s := &SharedStruct{ +12| ID: 1, +13| Name: "test", +14| Value: 42.0, +15| Constants: []string{SharedConstant}, +16| } +17| +18| // Call methods on the struct +19| fmt.Println(s.Method()) +20| s.Process() diff --git a/integrationtests/snapshots/go/references_headless/shared-interface.snap b/integrationtests/snapshots/go/references_headless/shared-interface.snap new file mode 100644 index 00000000..b3ac4694 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/shared-interface.snap @@ -0,0 +1,39 @@ +--- + +/TEST_OUTPUT/workspace/another_consumer.go +References in File: 1 +At: L33:C12 + +6|func AnotherConsumer() { +... +28| custom := &CustomImplementor{ +29| SharedStruct: *s, +30| } +31| +32| // Custom type implements SharedInterface through embedding +33| var iface SharedInterface = custom +34| iface.Process() +35| +36| // Use shared type as a slice type +37| values := []SharedType{1, 2, 3} +38| for _, v := range values { + +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L23:C12 + +6|func ConsumerFunction() { +... +18| // Call methods on the struct +19| fmt.Println(s.Method()) +20| s.Process() +21| +22| // Use shared interface +23| var iface SharedInterface = s +24| fmt.Println(iface.GetName()) +25| +26| // Use shared type +27| var t SharedType = 100 +28| fmt.Println(t) diff --git a/integrationtests/snapshots/go/references_headless/shared-struct.snap b/integrationtests/snapshots/go/references_headless/shared-struct.snap new file mode 100644 index 00000000..4c281d59 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/shared-struct.snap @@ -0,0 +1,66 @@ +--- + +/TEST_OUTPUT/workspace/another_consumer.go +References in File: 2 +At: L11:C8, L25:C3 + + 6|func AnotherConsumer() { + 7| // Use helper function + 8| fmt.Println("Another message:", HelperFunction()) + 9| +10| // Create another SharedStruct instance +11| s := &SharedStruct{ +12| ID: 2, +13| Name: "another test", +14| Value: 99.9, +15| Constants: []string{SharedConstant, "extra"}, +16| } +... +20| fmt.Println("Got name:", name) +21| } +22| +23| // Implement the interface with a custom type +24| type CustomImplementor struct { +25| SharedStruct +26| } +27| +28| custom := &CustomImplementor{ +29| SharedStruct: *s, +30| } + +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L11:C8 + + 6|func ConsumerFunction() { + 7| message := HelperFunction() + 8| fmt.Println(message) + 9| +10| // Use shared struct +11| s := &SharedStruct{ +12| ID: 1, +13| Name: "test", +14| Value: 42.0, +15| Constants: []string{SharedConstant}, +16| } + +--- + +/TEST_OUTPUT/workspace/types.go +References in File: 3 +At: L14:C10, L31:C10, L37:C10 + +14|func (s *SharedStruct) Method() string { +15| return s.Name +16|} +... +31|func (s *SharedStruct) Process() error { +32| fmt.Printf("Processing %s with ID %d\n", s.Name, s.ID) +33| return nil +34|} +... +37|func (s *SharedStruct) GetName() string { +38| return s.Name +39|} diff --git a/integrationtests/snapshots/go/references_headless/shared-type.snap b/integrationtests/snapshots/go/references_headless/shared-type.snap new file mode 100644 index 00000000..54544bf2 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/shared-type.snap @@ -0,0 +1,35 @@ +--- + +/TEST_OUTPUT/workspace/another_consumer.go +References in File: 1 +At: L37:C14 + +6|func AnotherConsumer() { +... +32| // Custom type implements SharedInterface through embedding +33| var iface SharedInterface = custom +34| iface.Process() +35| +36| // Use shared type as a slice type +37| values := []SharedType{1, 2, 3} +38| for _, v := range values { +39| fmt.Println("Value:", v) +40| } +41|} + +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L27:C8 + +6|func ConsumerFunction() { +... +22| // Use shared interface +23| var iface SharedInterface = s +24| fmt.Println(iface.GetName()) +25| +26| // Use shared type +27| var t SharedType = 100 +28| fmt.Println(t) +29|} diff --git a/integrationtests/snapshots/go/references_headless/struct-method.snap b/integrationtests/snapshots/go/references_headless/struct-method.snap new file mode 100644 index 00000000..65278a25 --- /dev/null +++ b/integrationtests/snapshots/go/references_headless/struct-method.snap @@ -0,0 +1,19 @@ +--- + +/TEST_OUTPUT/workspace/consumer.go +References in File: 1 +At: L19:C16 + +6|func ConsumerFunction() { +... +14| Value: 42.0, +15| Constants: []string{SharedConstant}, +16| } +17| +18| // Call methods on the struct +19| fmt.Println(s.Method()) +20| s.Process() +21| +22| // Use shared interface +23| var iface SharedInterface = s +24| fmt.Println(iface.GetName()) diff --git a/integrationtests/tests/common/framework.go b/integrationtests/tests/common/framework.go index e703fd0a..22689c8e 100644 --- a/integrationtests/tests/common/framework.go +++ b/integrationtests/tests/common/framework.go @@ -157,26 +157,16 @@ func (ts *TestSuite) Setup() error { ts.WorkspaceDir = workspaceDir ts.t.Logf("Copied workspace from %s to %s", ts.Config.WorkspaceDir, workspaceDir) - // Create and initialize LSP client - var client *lsp.Client + // Create and initialize LSP client (always start subprocess; headless/ConnectAddr is not used) if ts.Config.ConnectAddr != "" { - ts.headless = true - var err error - client, err = lsp.NewClientHeadless(ts.Config.ConnectAddr) - if err != nil { - return fmt.Errorf("failed to connect to LSP at %s: %w", ts.Config.ConnectAddr, err) - } - ts.Client = client - ts.t.Logf("Connected to LSP at %s (headless)", ts.Config.ConnectAddr) - } else { - var err error - client, err = lsp.NewClient(ts.Config.Command, ts.Config.Args...) - if err != nil { - return fmt.Errorf("failed to create LSP client: %w", err) - } - ts.Client = client - ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) + return fmt.Errorf("headless mode is disabled; config must use Command/Args to start the LSP in integration tests.") + } + client, err := lsp.NewClient(ts.Config.Command, ts.Config.Args...) + if err != nil { + return fmt.Errorf("failed to create LSP client: %w", err) } + ts.Client = client + ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) // Initialize LSP and set up file watcher initResult, err := client.InitializeLSPClient(ts.Context, workspaceDir) From 2a7799cf976209ec196fc2e60e0b66e2115cd23d Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sat, 7 Feb 2026 16:48:16 -0500 Subject: [PATCH 5/7] Implement headless Go integration tests --- integrationtests/tests/common/framework.go | 126 ++++++-- .../tests/go/codelens/codelens_test.go | 175 ++++++------ .../go/definition/definition_headless_test.go | 52 ---- .../tests/go/definition/definition_test.go | 42 ++- .../diagnostics/diagnostics_headless_test.go | 157 ++++++++++ .../tests/go/diagnostics/diagnostics_test.go | 269 +++++++++--------- integrationtests/tests/go/hover/hover_test.go | 67 +++-- integrationtests/tests/go/internal/helpers.go | 27 +- .../go/references/references_headless_test.go | 53 ---- .../tests/go/references/references_test.go | 54 ++-- .../go/rename_symbol/rename_symbol_test.go | 150 +++++----- .../tests/go/text_edit/text_edit_test.go | 178 ++++++------ 12 files changed, 763 insertions(+), 587 deletions(-) delete mode 100644 integrationtests/tests/go/definition/definition_headless_test.go create mode 100644 integrationtests/tests/go/diagnostics/diagnostics_headless_test.go delete mode 100644 integrationtests/tests/go/references/references_headless_test.go diff --git a/integrationtests/tests/common/framework.go b/integrationtests/tests/common/framework.go index 22689c8e..09fb19cb 100644 --- a/integrationtests/tests/common/framework.go +++ b/integrationtests/tests/common/framework.go @@ -4,8 +4,11 @@ import ( "context" "fmt" "log" + "net" "os" + "os/exec" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -18,12 +21,13 @@ import ( // LSPTestConfig defines configuration for a language server test type LSPTestConfig struct { - Name string // Name of the language server - Command string // Command to run (ignored if ConnectAddr is set) - Args []string // Arguments (ignored if ConnectAddr is set) - ConnectAddr string // If set, connect to existing LSP at this address (headless) instead of starting Command - WorkspaceDir string // Template workspace directory - InitializeTimeMs int // Time to wait after initialization in ms + Name string // Name of the language server + Command string // Command to run (ignored if ConnectAddr is set) + Args []string // Arguments (ignored if ConnectAddr is set) + ConnectAddr string // If set, connect to existing LSP at this address (headless) instead of starting Command + HeadlessListenArg string // If set, start LSP with this listen arg (e.g. "-listen=127.0.0.1:%d") and connect via NewClientHeadless + WorkspaceDir string // Template workspace directory + InitializeTimeMs int // Time to wait after initialization in ms } // TestSuite contains everything needed for running integration tests @@ -40,7 +44,8 @@ type TestSuite struct { logFile string t *testing.T LanguageName string - headless bool // true when using ConnectAddr (skip shutdown/exit on cleanup) + headless bool // true when using ConnectAddr or HeadlessListenArg (affects cleanup) + headlessCmd *exec.Cmd // when we start LSP in listen mode, the process we started (for cleanup) } // NewTestSuite creates a new test suite for the given language server @@ -56,6 +61,45 @@ func NewTestSuite(t *testing.T, config LSPTestConfig) *TestSuite { } } +// startLSPInListenMode reserves a port, starts the LSP with the same Command/Args plus +// HeadlessListenArg (with %d replaced by the port), and waits until the server accepts connections. +// Caller must connect with NewClientHeadless(addr) and is responsible for killing the process on cleanup. +func (ts *TestSuite) startLSPInListenMode(workspaceDir string) (addr string, cmd *exec.Cmd, err error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", nil, fmt.Errorf("failed to reserve port: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + return "", nil, fmt.Errorf("failed to close listener: %w", err) + } + addr = "127.0.0.1:" + strconv.Itoa(port) + listenArg := fmt.Sprintf(ts.Config.HeadlessListenArg, port) + fullArgs := append(append([]string{}, ts.Config.Args...), listenArg) + cmd = exec.Command(ts.Config.Command, fullArgs...) + cmd.Env = os.Environ() + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("failed to start LSP: %w", err) + } + // Wait for server to accept connections (retry with backoff) + const maxWait = 15 * time.Second + deadline := time.Now().Add(maxWait) + for time.Now().Before(deadline) { + conn, dialErr := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if dialErr == nil { + conn.Close() + return addr, cmd, nil + } + time.Sleep(100 * time.Millisecond) + } + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return "", nil, fmt.Errorf("LSP at %s did not accept connections within %v", addr, maxWait) +} + // Setup initializes the test suite, copies the workspace, and starts the LSP func (ts *TestSuite) Setup() error { if ts.initialized { @@ -157,16 +201,36 @@ func (ts *TestSuite) Setup() error { ts.WorkspaceDir = workspaceDir ts.t.Logf("Copied workspace from %s to %s", ts.Config.WorkspaceDir, workspaceDir) - // Create and initialize LSP client (always start subprocess; headless/ConnectAddr is not used) - if ts.Config.ConnectAddr != "" { - return fmt.Errorf("headless mode is disabled; config must use Command/Args to start the LSP in integration tests.") - } - client, err := lsp.NewClient(ts.Config.Command, ts.Config.Args...) - if err != nil { - return fmt.Errorf("failed to create LSP client: %w", err) + // Create and initialize LSP client + var client *lsp.Client + if ts.Config.HeadlessListenArg != "" { + // Start LSP in listen mode (same Command/Args as NewClient), then connect via NewClientHeadless + addr, cmd, err := ts.startLSPInListenMode(workspaceDir) + if err != nil { + return err + } + ts.headlessCmd = cmd + ts.headless = true + client, err = lsp.NewClientHeadless(addr) + if err != nil { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return fmt.Errorf("failed to connect to LSP at %s: %w", addr, err) + } + ts.Client = client + ts.t.Logf("Started LSP in listen mode and connected at %s", addr) + } else if ts.Config.ConnectAddr != "" { + return fmt.Errorf("headless via ConnectAddr is disabled; use HeadlessListenArg to run headless (start LSP in listen mode and connect)") + } else { + var err error + client, err = lsp.NewClient(ts.Config.Command, ts.Config.Args...) + if err != nil { + return fmt.Errorf("failed to create LSP client: %w", err) + } + ts.Client = client + ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) } - ts.Client = client - ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) // Initialize LSP and set up file watcher initResult, err := client.InitializeLSPClient(ts.Context, workspaceDir) @@ -202,7 +266,7 @@ func (ts *TestSuite) Cleanup() { // Cancel context to stop watchers ts.Cancel() - // Shutdown LSP (headless: only close connection; subprocess: shutdown + exit + close) + // Shutdown LSP: for subprocess we shutdown+exit+close; for headless we only close unless we started the process if ts.Client != nil { if !ts.headless { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -215,11 +279,39 @@ func (ts *TestSuite) Cleanup() { if err := ts.Client.Exit(shutdownCtx); err != nil { ts.t.Logf("Exit failed: %v", err) } + } else if ts.headlessCmd != nil { + // We started the LSP in listen mode; send shutdown/exit so it exits gracefully + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ts.t.Logf("Shutting down LSP client (headless subprocess)") + if err := ts.Client.Shutdown(shutdownCtx); err != nil { + ts.t.Logf("Shutdown failed: %v", err) + } + if err := ts.Client.Exit(shutdownCtx); err != nil { + ts.t.Logf("Exit failed: %v", err) + } } if err := ts.Client.Close(); err != nil { ts.t.Logf("Close failed: %v", err) } } + if ts.headlessCmd != nil { + done := make(chan struct{}) + go func() { + _ = ts.headlessCmd.Wait() + close(done) + }() + select { + case <-done: + // process exited + case <-time.After(3 * time.Second): + if ts.headlessCmd.Process != nil { + ts.t.Logf("Killing LSP process after timeout") + _ = ts.headlessCmd.Process.Kill() + _ = ts.headlessCmd.Wait() + } + } + } // No need to close log files explicitly, logging package handles that diff --git a/integrationtests/tests/go/codelens/codelens_test.go b/integrationtests/tests/go/codelens/codelens_test.go index b6ef165d..16afe2c2 100644 --- a/integrationtests/tests/go/codelens/codelens_test.go +++ b/integrationtests/tests/go/codelens/codelens_test.go @@ -12,90 +12,101 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestCodeLens tests the codelens functionality with the Go language server +// TestCodeLens tests the codelens functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestCodeLens(t *testing.T) { t.Skip("Remove this line to run codelens tool tests") - // Test GetCodeLens with a file that should have codelenses - t.Run("GetCodeLens", func(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - // The go.mod fixture already has an unused dependency - - // Wait for LSP to process the file - time.Sleep(2 * time.Second) - - // Test GetCodeLens - filePath := filepath.Join(suite.WorkspaceDir, "go.mod") - result, err := tools.GetCodeLens(ctx, suite.Client, filePath) - if err != nil { - t.Fatalf("GetCodeLens failed: %v", err) - } - - // Verify we have at least one code lens - if !strings.Contains(result, "Code Lens results") { - t.Errorf("Expected code lens results but got: %s", result) - } - - // Verify we have a "go mod tidy" code lens - if !strings.Contains(strings.ToLower(result), "tidy") { - t.Errorf("Expected 'tidy' code lens but got: %s", result) - } - - common.SnapshotTest(t, "go", "codelens", "get", result) - }) - - // Test ExecuteCodeLens by running the tidy codelens command - t.Run("ExecuteCodeLens", func(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - // The go.mod fixture already has an unused dependency - // Wait for LSP to process the file - time.Sleep(2 * time.Second) - - // First get the code lenses to find the right index - filePath := filepath.Join(suite.WorkspaceDir, "go.mod") - result, err := tools.GetCodeLens(ctx, suite.Client, filePath) - if err != nil { - t.Fatalf("GetCodeLens failed: %v", err) - } - - // Make sure we have a code lens with "tidy" in it - if !strings.Contains(strings.ToLower(result), "tidy") { - t.Fatalf("Expected 'tidy' code lens but none found: %s", result) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + snapshotCategory := "codelens" + if mode.headless { + snapshotCategory = "codelens_headless" } - - // Typically, the tidy lens should be index 2 (1-based) for gopls, but let's log for debugging - t.Logf("Code lenses: %s", result) - - // Execute the code lens (use index 2 which should be the tidy lens) - execResult, err := tools.ExecuteCodeLens(ctx, suite.Client, filePath, 2) - if err != nil { - t.Fatalf("ExecuteCodeLens failed: %v", err) - } - - t.Logf("ExecuteCodeLens result: %s", execResult) - - // Wait for LSP to update the file - time.Sleep(3 * time.Second) - - // Check if the file was updated (dependency should be removed) - updatedContent, err := suite.ReadFile("go.mod") - if err != nil { - t.Fatalf("Failed to read updated go.mod: %v", err) - } - - // Verify the dependency is gone - if strings.Contains(updatedContent, "github.com/stretchr/testify") { - t.Errorf("Expected dependency to be removed, but it's still there:\n%s", updatedContent) - } - - common.SnapshotTest(t, "go", "codelens", "execute", execResult) - }) + t.Run(mode.name, func(t *testing.T) { + t.Run("GetCodeLens", func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // The go.mod fixture already has an unused dependency + + // Wait for LSP to process the file + time.Sleep(2 * time.Second) + + // Test GetCodeLens + filePath := filepath.Join(suite.WorkspaceDir, "go.mod") + result, err := tools.GetCodeLens(ctx, suite.Client, filePath) + if err != nil { + t.Fatalf("GetCodeLens failed: %v", err) + } + + // Verify we have at least one code lens + if !strings.Contains(result, "Code Lens results") { + t.Errorf("Expected code lens results but got: %s", result) + } + + // Verify we have a "go mod tidy" code lens + if !strings.Contains(strings.ToLower(result), "tidy") { + t.Errorf("Expected 'tidy' code lens but got: %s", result) + } + + common.SnapshotTest(t, "go", snapshotCategory, "get", result) + }) + + t.Run("ExecuteCodeLens", func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) + + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + // The go.mod fixture already has an unused dependency + // Wait for LSP to process the file + time.Sleep(2 * time.Second) + + // First get the code lenses to find the right index + filePath := filepath.Join(suite.WorkspaceDir, "go.mod") + result, err := tools.GetCodeLens(ctx, suite.Client, filePath) + if err != nil { + t.Fatalf("GetCodeLens failed: %v", err) + } + + // Make sure we have a code lens with "tidy" in it + if !strings.Contains(strings.ToLower(result), "tidy") { + t.Fatalf("Expected 'tidy' code lens but none found: %s", result) + } + + // Typically, the tidy lens should be index 2 (1-based) for gopls, but let's log for debugging + t.Logf("Code lenses: %s", result) + + // Execute the code lens (use index 2 which should be the tidy lens) + execResult, err := tools.ExecuteCodeLens(ctx, suite.Client, filePath, 2) + if err != nil { + t.Fatalf("ExecuteCodeLens failed: %v", err) + } + + t.Logf("ExecuteCodeLens result: %s", execResult) + + // Wait for LSP to update the file + time.Sleep(3 * time.Second) + + // Check if the file was updated (dependency should be removed) + updatedContent, err := suite.ReadFile("go.mod") + if err != nil { + t.Fatalf("Failed to read updated go.mod: %v", err) + } + + // Verify the dependency is gone + if strings.Contains(updatedContent, "github.com/stretchr/testify") { + t.Errorf("Expected dependency to be removed, but it's still there:\n%s", updatedContent) + } + + common.SnapshotTest(t, "go", snapshotCategory, "execute", execResult) + }) + }) + } } diff --git a/integrationtests/tests/go/definition/definition_headless_test.go b/integrationtests/tests/go/definition/definition_headless_test.go deleted file mode 100644 index 48293c63..00000000 --- a/integrationtests/tests/go/definition/definition_headless_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package definition_test - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" - "github.com/isaacphi/mcp-language-server/integrationtests/tests/go/internal" - "github.com/isaacphi/mcp-language-server/internal/tools" -) - -// TestReadDefinitionHeadless runs ReadDefinition against an already-running gopls. -// Requires GOPLS_HEADLESS_ADDR (e.g. localhost:6060). The server must be started separately (e.g. gopls -listen=:6060). -func TestReadDefinitionHeadless(t *testing.T) { - suite := internal.GetHeadlessTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - tests := []struct { - name string - symbolName string - expectedText string - snapshotName string - }{ - {"Function", "FooBar", "func FooBar()", "foobar"}, - {"Struct", "TestStruct", "type TestStruct struct", "struct"}, - {"Method", "TestStruct.Method", "func (t *TestStruct) Method()", "method"}, - {"Interface", "TestInterface", "type TestInterface interface", "interface"}, - {"Type", "TestType", "type TestType string", "type"}, - {"Constant", "TestConstant", "const TestConstant", "constant"}, - {"Variable", "TestVariable", "var TestVariable", "variable"}, - {"TestFunction", "TestFunction", "func TestFunction()", "function"}, - {"NotFound", "NotFound", "not found", "not-found"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) - if err != nil { - t.Fatalf("ReadDefinition failed: %v", err) - } - // Headless server may have a different workspace; only assert NotFound case - if tc.snapshotName == "not-found" && !strings.Contains(result, "not found") { - t.Errorf("expected 'not found' in result for unknown symbol, got: %s", result) - } - common.SnapshotTest(t, "go", "definition_headless", tc.snapshotName, result) - }) - } -} diff --git a/integrationtests/tests/go/definition/definition_test.go b/integrationtests/tests/go/definition/definition_test.go index 39ed6d8e..d8f4adcf 100644 --- a/integrationtests/tests/go/definition/definition_test.go +++ b/integrationtests/tests/go/definition/definition_test.go @@ -11,13 +11,9 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestReadDefinition tests the ReadDefinition tool with various Go type definitions +// TestReadDefinition tests the ReadDefinition tool with various Go type definitions. +// Runs in both subprocess and headless (listen-mode) modes. func TestReadDefinition(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - tests := []struct { name string symbolName string @@ -80,18 +76,34 @@ func TestReadDefinition(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Call the ReadDefinition tool - result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) - if err != nil { - t.Fatalf("Failed to read definition: %v", err) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + snapshotCategory := "definition" + if mode.headless { + snapshotCategory = "definition_headless" } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // Call the ReadDefinition tool + result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) + if err != nil { + t.Fatalf("Failed to read definition: %v", err) + } + // Check that the result contains relevant information - if !strings.Contains(result, tc.expectedText) { - t.Errorf("Definition does not contain expected text: %s", tc.expectedText) - } + if !strings.Contains(result, tc.expectedText) { + t.Errorf("Definition does not contain expected text: %s", tc.expectedText) + } // Use snapshot testing to verify exact output common.SnapshotTest(t, "go", "definition", tc.snapshotName, result) diff --git a/integrationtests/tests/go/diagnostics/diagnostics_headless_test.go b/integrationtests/tests/go/diagnostics/diagnostics_headless_test.go new file mode 100644 index 00000000..09c66aec --- /dev/null +++ b/integrationtests/tests/go/diagnostics/diagnostics_headless_test.go @@ -0,0 +1,157 @@ +package diagnostics_test + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" + "github.com/isaacphi/mcp-language-server/integrationtests/tests/go/internal" + "github.com/isaacphi/mcp-language-server/internal/protocol" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestDiagnosticsHeadless runs the same diagnostics tests with gopls started in listen mode and connected via NewClientHeadless. +func TestDiagnosticsHeadless(t *testing.T) { + t.Run("CleanFile", func(t *testing.T) { + suite := internal.GetHeadlessTestSuite(t) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + filePath := filepath.Join(suite.WorkspaceDir, "clean.go") + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + if !strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected no diagnostics but got: %s", result) + } + + common.SnapshotTest(t, "go", "diagnostics_headless", "clean", result) + }) + + t.Run("FileWithError", func(t *testing.T) { + suite := internal.GetHeadlessTestSuite(t) + + time.Sleep(2 * time.Second) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + filePath := filepath.Join(suite.WorkspaceDir, "main.go") + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + if strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected diagnostics but got none") + } + + if !strings.Contains(result, "unreachable") { + t.Errorf("Expected unreachable code error but got: %s", result) + } + + common.SnapshotTest(t, "go", "diagnostics_headless", "unreachable", result) + }) + + t.Run("FileDependency", func(t *testing.T) { + suite := internal.GetHeadlessTestSuite(t) + + time.Sleep(2 * time.Second) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") + consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") + + err := suite.Client.OpenFile(ctx, helperPath) + if err != nil { + t.Fatalf("Failed to open helper.go: %v", err) + } + + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to open consumer.go: %v", err) + } + + time.Sleep(2 * time.Second) + + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + if !strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected no diagnostics initially but got: %s", result) + } + + modifiedHelperContent := `package main + +// HelperFunction now requires an int parameter +func HelperFunction(value int) string { + return "hello world" +} +` + err = suite.WriteFile("helper.go", modifiedHelperContent) + if err != nil { + t.Fatalf("Failed to update helper.go: %v", err) + } + + helperURI := fmt.Sprintf("file://%s", helperPath) + + err = suite.Client.NotifyChange(ctx, helperPath) + if err != nil { + t.Fatalf("Failed to notify change to helper.go: %v", err) + } + + fileChangeParams := protocol.DidChangeWatchedFilesParams{ + Changes: []protocol.FileEvent{ + { + URI: protocol.DocumentUri(helperURI), + Type: protocol.FileChangeType(protocol.Changed), + }, + }, + } + + err = suite.Client.DidChangeWatchedFiles(ctx, fileChangeParams) + if err != nil { + t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) + } + + time.Sleep(3 * time.Second) + + err = suite.Client.CloseFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to close consumer.go: %v", err) + } + + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to reopen consumer.go: %v", err) + } + + time.Sleep(3 * time.Second) + + result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) + } + + if strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected diagnostics after dependency change but got none") + } + + if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { + t.Errorf("Expected error about wrong arguments but got: %s", result) + } + + common.SnapshotTest(t, "go", "diagnostics_headless", "dependency", result) + }) +} diff --git a/integrationtests/tests/go/diagnostics/diagnostics_test.go b/integrationtests/tests/go/diagnostics/diagnostics_test.go index 7b7a6660..cff47014 100644 --- a/integrationtests/tests/go/diagnostics/diagnostics_test.go +++ b/integrationtests/tests/go/diagnostics/diagnostics_test.go @@ -14,172 +14,159 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestDiagnostics tests diagnostics functionality with the Go language server +// TestDiagnostics tests diagnostics functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestDiagnostics(t *testing.T) { - // Test with a clean file - t.Run("CleanFile", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - filePath := filepath.Join(suite.WorkspaceDir, "clean.go") - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + snapshotCategory := "diagnostics" + if mode.headless { + snapshotCategory = "diagnostics_headless" } + t.Run(mode.name, func(t *testing.T) { + t.Run("CleanFile", func(t *testing.T) { + // Get a test suite with clean code + // suite := internal.GetTestSuiteForMode(t, mode.headless) - // Verify we have no diagnostics - if !strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected no diagnostics but got: %s", result) - } + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() - common.SnapshotTest(t, "go", "diagnostics", "clean", result) - }) + filePath := filepath.Join(suite.WorkspaceDir, "clean.go") + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } - // Test with a file containing an error - t.Run("FileWithError", func(t *testing.T) { - // Get a test suite with code that contains errors - suite := internal.GetTestSuite(t) + // Verify we have no diagnostics + if !strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected no diagnostics but got: %s", result) + } - // Wait for diagnostics to be generated - time.Sleep(2 * time.Second) + common.SnapshotTest(t, "go", snapshotCategory, "clean", result) + }) - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() + t.Run("FileWithError", func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) - filePath := filepath.Join(suite.WorkspaceDir, "main.go") - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } + time.Sleep(2 * time.Second) - // Verify we have diagnostics about unreachable code - if strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected diagnostics but got none") - } + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() - if !strings.Contains(result, "unreachable") { - t.Errorf("Expected unreachable code error but got: %s", result) - } + filePath := filepath.Join(suite.WorkspaceDir, "main.go") + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } - common.SnapshotTest(t, "go", "diagnostics", "unreachable", result) - }) + if strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected diagnostics but got none") + } - // Test file dependency: file A (helper.go) provides a function, - // file B (consumer.go) uses it, then modify A to break B - t.Run("FileDependency", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) + if !strings.Contains(result, "unreachable") { + t.Errorf("Expected unreachable code error but got: %s", result) + } - // Wait for initial diagnostics to be generated - time.Sleep(2 * time.Second) + common.SnapshotTest(t, "go", snapshotCategory, "unreachable", result) + }) - // Verify consumer.go is clean initially - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() + t.Run("FileDependency", func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) - // Ensure both helper.go and consumer.go are open in the LSP - helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") - consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") + time.Sleep(2 * time.Second) - err := suite.Client.OpenFile(ctx, helperPath) - if err != nil { - t.Fatalf("Failed to open helper.go: %v", err) - } + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() - err = suite.Client.OpenFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to open consumer.go: %v", err) - } + helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") + consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") - // Wait for files to be processed - time.Sleep(2 * time.Second) + err := suite.Client.OpenFile(ctx, helperPath) + if err != nil { + t.Fatalf("Failed to open helper.go: %v", err) + } - // Get initial diagnostics for consumer.go - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to open consumer.go: %v", err) + } - // Should have no diagnostics initially - if !strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected no diagnostics initially but got: %s", result) - } + time.Sleep(2 * time.Second) - // Now modify the helper function to cause an error in the consumer - modifiedHelperContent := `package main + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + if !strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected no diagnostics initially but got: %s", result) + } + + modifiedHelperContent := `package main // HelperFunction now requires an int parameter func HelperFunction(value int) string { return "hello world" } ` - // Write the modified content to the file - err = suite.WriteFile("helper.go", modifiedHelperContent) - if err != nil { - t.Fatalf("Failed to update helper.go: %v", err) - } - - // Explicitly notify the LSP server about the change - helperURI := fmt.Sprintf("file://%s", helperPath) - - // Notify the LSP server about the file change - err = suite.Client.NotifyChange(ctx, helperPath) - if err != nil { - t.Fatalf("Failed to notify change to helper.go: %v", err) - } - - // Also send a didChangeWatchedFiles notification for coverage - // This simulates what the watcher would do - fileChangeParams := protocol.DidChangeWatchedFilesParams{ - Changes: []protocol.FileEvent{ - { - URI: protocol.DocumentUri(helperURI), - Type: protocol.FileChangeType(protocol.Changed), - }, - }, - } - - err = suite.Client.DidChangeWatchedFiles(ctx, fileChangeParams) - if err != nil { - t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) - } - - // Wait for LSP to process the change - time.Sleep(3 * time.Second) - - // Force reopen the consumer file to ensure LSP reevaluates it - err = suite.Client.CloseFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to close consumer.go: %v", err) - } - - err = suite.Client.OpenFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to reopen consumer.go: %v", err) - } - - // Wait for diagnostics to be generated - time.Sleep(3 * time.Second) - - // Check diagnostics again on consumer file - should now have an error - result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) - } - - // Should have diagnostics now - if strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected diagnostics after dependency change but got none") - } - - // Should contain an error about function arguments - if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { - t.Errorf("Expected error about wrong arguments but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics", "dependency", result) - }) + err = suite.WriteFile("helper.go", modifiedHelperContent) + if err != nil { + t.Fatalf("Failed to update helper.go: %v", err) + } + + helperURI := fmt.Sprintf("file://%s", helperPath) + + err = suite.Client.NotifyChange(ctx, helperPath) + if err != nil { + t.Fatalf("Failed to notify change to helper.go: %v", err) + } + + fileChangeParams := protocol.DidChangeWatchedFilesParams{ + Changes: []protocol.FileEvent{ + { + URI: protocol.DocumentUri(helperURI), + Type: protocol.FileChangeType(protocol.Changed), + }, + }, + } + + err = suite.Client.DidChangeWatchedFiles(ctx, fileChangeParams) + if err != nil { + t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) + } + + time.Sleep(3 * time.Second) + + err = suite.Client.CloseFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to close consumer.go: %v", err) + } + + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to reopen consumer.go: %v", err) + } + + time.Sleep(3 * time.Second) + + result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) + } + + if strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected diagnostics after dependency change but got none") + } + + if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { + t.Errorf("Expected error about wrong arguments but got: %s", result) + } + + common.SnapshotTest(t, "go", snapshotCategory, "dependency", result) + }) + }) + } } diff --git a/integrationtests/tests/go/hover/hover_test.go b/integrationtests/tests/go/hover/hover_test.go index f8796313..6b5b2315 100644 --- a/integrationtests/tests/go/hover/hover_test.go +++ b/integrationtests/tests/go/hover/hover_test.go @@ -12,7 +12,8 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestHover tests hover functionality with the Go language server +// TestHover tests hover functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestHover(t *testing.T) { tests := []struct { name string @@ -84,43 +85,49 @@ func TestHover(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Get a test suite - suite := internal.GetTestSuite(t) - + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() - filePath := filepath.Join(suite.WorkspaceDir, tt.file) - err := suite.Client.OpenFile(ctx, filePath) - if err != nil { - t.Fatalf("Failed to open %s: %v", tt.file, err) + snapshotCategory := "hover" + if mode.headless { + snapshotCategory = "hover_headless" } - // Get hover info - result, err := tools.GetHoverInfo(ctx, suite.Client, filePath, tt.line, tt.column) - if err != nil { - // For the "OutsideFile" test, we expect an error - if tt.name == "OutsideFile" { - // Create a snapshot even for error case - common.SnapshotTest(t, "go", "hover", tt.snapshotName, err.Error()) - return - } - t.Fatalf("GetHoverInfo failed: %v", err) - } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(suite.WorkspaceDir, tt.file) + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open %s: %v", tt.file, err) + } - // Verify expected content - if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) { - t.Errorf("Expected hover info to contain %q but got: %s", tt.expectedText, result) - } + result, err := tools.GetHoverInfo(ctx, suite.Client, filePath, tt.line, tt.column) + if err != nil { + if tt.name == "OutsideFile" { + common.SnapshotTest(t, "go", snapshotCategory, tt.snapshotName, err.Error()) + return + } + t.Fatalf("GetHoverInfo failed: %v", err) + } - // Verify unexpected content is absent - if tt.unexpectedText != "" && strings.Contains(result, tt.unexpectedText) { - t.Errorf("Expected hover info NOT to contain %q but it was found: %s", tt.unexpectedText, result) - } + if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) { + t.Errorf("Expected hover info to contain %q but got: %s", tt.expectedText, result) + } + if tt.unexpectedText != "" && strings.Contains(result, tt.unexpectedText) { + t.Errorf("Expected hover info NOT to contain %q but it was found: %s", tt.unexpectedText, result) + } - common.SnapshotTest(t, "go", "hover", tt.snapshotName, result) + common.SnapshotTest(t, "go", snapshotCategory, tt.snapshotName, result) + }) + } }) } } diff --git a/integrationtests/tests/go/internal/helpers.go b/integrationtests/tests/go/internal/helpers.go index 7e1dc8f9..fd20ba17 100644 --- a/integrationtests/tests/go/internal/helpers.go +++ b/integrationtests/tests/go/internal/helpers.go @@ -2,7 +2,6 @@ package internal import ( - "os" "path/filepath" "testing" @@ -32,24 +31,30 @@ func GetTestSuite(t *testing.T) *common.TestSuite { return suite } -// GetHeadlessTestSuite returns a test suite that connects to an existing gopls at GOPLS_HEADLESS_ADDR. -// Skips the test if GOPLS_HEADLESS_ADDR is not set. The server must be started separately (e.g. gopls -listen=:6060). -func GetHeadlessTestSuite(t *testing.T) *common.TestSuite { - addr := os.Getenv("GOPLS_HEADLESS_ADDR") - if addr == "" { - t.Skip("GOPLS_HEADLESS_ADDR not set; set to e.g. localhost:6060 to run headless tests (gopls must be running with -listen=:6060)") +// GetTestSuiteForMode returns a test suite for the given mode. When headless is true, starts gopls in listen +// mode and connects via NewClientHeadless; otherwise starts gopls as a subprocess. +func GetTestSuiteForMode(t *testing.T, headless bool) *common.TestSuite { + if headless { + return GetHeadlessTestSuite(t) } + return GetTestSuite(t) +} +// GetHeadlessTestSuite returns a test suite that starts gopls in listen mode (same Command/Args as GetTestSuite) +// and connects via NewClientHeadless. No external server or GOPLS_HEADLESS_ADDR is required. +func GetHeadlessTestSuite(t *testing.T) *common.TestSuite { repoRoot, err := filepath.Abs("../../../..") if err != nil { t.Fatalf("Failed to get repo root: %v", err) } config := common.LSPTestConfig{ - Name: "go", - ConnectAddr: addr, - WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), - InitializeTimeMs: 2000, + Name: "go", + Command: "gopls", + Args: []string{}, + HeadlessListenArg: "-listen=127.0.0.1:%d", + WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), + InitializeTimeMs: 2000, } suite := common.NewTestSuite(t, config) diff --git a/integrationtests/tests/go/references/references_headless_test.go b/integrationtests/tests/go/references/references_headless_test.go deleted file mode 100644 index c63accb3..00000000 --- a/integrationtests/tests/go/references/references_headless_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package references_test - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" - "github.com/isaacphi/mcp-language-server/integrationtests/tests/go/internal" - "github.com/isaacphi/mcp-language-server/internal/tools" -) - -// TestFindReferencesHeadless runs FindReferences against an already-running gopls. -// Requires GOPLS_HEADLESS_ADDR (e.g. localhost:6060). The server must be started separately (e.g. gopls -listen=:6060). -func TestFindReferencesHeadless(t *testing.T) { - suite := internal.GetHeadlessTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - tests := []struct { - name string - symbolName string - expectedText string - expectedFiles int - snapshotName string - }{ - {"Function across files", "HelperFunction", "ConsumerFunction", 2, "helper-function"}, - {"Function same file", "FooBar", "main()", 1, "foobar-function"}, - {"Struct across files", "SharedStruct", "ConsumerFunction", 2, "shared-struct"}, - {"Method", "SharedStruct.Method", "s.Method()", 1, "struct-method"}, - {"Interface", "SharedInterface", "var iface SharedInterface", 2, "shared-interface"}, - {"Interface method", "SharedInterface.GetName", "iface.GetName()", 1, "interface-method"}, - {"Constant", "SharedConstant", "SharedConstant", 2, "shared-constant"}, - {"Type", "SharedType", "SharedType", 2, "shared-type"}, - {"NotFound", "NotFound", "No references found for symbol:", 0, "not-found"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) - if err != nil { - t.Fatalf("FindReferences failed: %v", err) - } - // Headless server may have a different workspace; only assert NotFound case - if tc.snapshotName == "not-found" && !strings.Contains(result, "No references found") { - t.Errorf("expected 'No references found' for unknown symbol, got: %s", result) - } - common.SnapshotTest(t, "go", "references_headless", tc.snapshotName, result) - }) - } -} diff --git a/integrationtests/tests/go/references/references_test.go b/integrationtests/tests/go/references/references_test.go index 895aaeef..30ce3551 100644 --- a/integrationtests/tests/go/references/references_test.go +++ b/integrationtests/tests/go/references/references_test.go @@ -12,13 +12,8 @@ import ( ) // TestFindReferences tests the FindReferences tool with Go symbols -// that have references across different files +// that have references across different files. Runs in both subprocess and headless (listen-mode) modes. func TestFindReferences(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - tests := []struct { name string symbolName string @@ -91,28 +86,39 @@ func TestFindReferences(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Call the FindReferences tool - result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) - if err != nil { - t.Fatalf("Failed to find references: %v", err) - } + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() - // Check that the result contains relevant information - if !strings.Contains(result, tc.expectedText) { - t.Errorf("References do not contain expected text: %s", tc.expectedText) + snapshotCategory := "references" + if mode.headless { + snapshotCategory = "references_headless" } - // Count how many different files are mentioned in the result - fileCount := countFilesInResult(result) - if fileCount < tc.expectedFiles { - t.Errorf("Expected references in at least %d files, but found in %d files", - tc.expectedFiles, fileCount) + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) + if err != nil { + t.Fatalf("Failed to find references: %v", err) + } + if !strings.Contains(result, tc.expectedText) { + t.Errorf("References do not contain expected text: %s", tc.expectedText) + } + fileCount := countFilesInResult(result) + if fileCount < tc.expectedFiles { + t.Errorf("Expected references in at least %d files, but found in %d files", + tc.expectedFiles, fileCount) + } + common.SnapshotTest(t, "go", snapshotCategory, tc.snapshotName, result) + }) } - - // Use snapshot testing to verify exact output - common.SnapshotTest(t, "go", "references", tc.snapshotName, result) }) } } diff --git a/integrationtests/tests/go/rename_symbol/rename_symbol_test.go b/integrationtests/tests/go/rename_symbol/rename_symbol_test.go index c5ea44a8..205ddaca 100644 --- a/integrationtests/tests/go/rename_symbol/rename_symbol_test.go +++ b/integrationtests/tests/go/rename_symbol/rename_symbol_test.go @@ -12,101 +12,95 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestRenameSymbol tests the RenameSymbol functionality with the Go language server +// TestRenameSymbol tests the RenameSymbol functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestRenameSymbol(t *testing.T) { - // Test with a successful rename of a symbol that exists - t.Run("SuccessfulRename", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) - - // Wait for initialization - time.Sleep(2 * time.Second) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - // Ensure the file is open - filePath := filepath.Join(suite.WorkspaceDir, "types.go") - err := suite.Client.OpenFile(ctx, filePath) - if err != nil { - t.Fatalf("Failed to open types.go: %v", err) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + snapshotCategory := "rename_symbol" + if mode.headless { + snapshotCategory = "rename_symbol_headless" } + t.Run(mode.name, func(t *testing.T) { + t.Run("SuccessfulRename", func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) - // Request to rename SharedConstant to UpdatedConstant at its definition - // The constant is defined at line 25, column 7 of types.go - result, err := tools.RenameSymbol(ctx, suite.Client, filePath, 25, 7, "UpdatedConstant") - if err != nil { - t.Fatalf("RenameSymbol failed: %v", err) - } + time.Sleep(2 * time.Second) - // Verify the constant was renamed - if !strings.Contains(result, "Successfully renamed symbol") { - t.Errorf("Expected success message but got: %s", result) - } + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() - // Verify it's mentioned that it renamed multiple occurrences - if !strings.Contains(result, "occurrences") { - t.Errorf("Expected multiple occurrences to be renamed but got: %s", result) - } + filePath := filepath.Join(suite.WorkspaceDir, "types.go") + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open types.go: %v", err) + } - common.SnapshotTest(t, "go", "rename_symbol", "successful", result) + result, err := tools.RenameSymbol(ctx, suite.Client, filePath, 25, 7, "UpdatedConstant") + if err != nil { + t.Fatalf("RenameSymbol failed: %v", err) + } - // Verify that the rename worked by checking for the updated constant name in the file - fileContent, err := suite.ReadFile("types.go") - if err != nil { - t.Fatalf("Failed to read types.go: %v", err) - } + if !strings.Contains(result, "Successfully renamed symbol") { + t.Errorf("Expected success message but got: %s", result) + } - if !strings.Contains(fileContent, "UpdatedConstant") { - t.Errorf("Expected to find renamed constant 'UpdatedConstant' in types.go") - } + if !strings.Contains(result, "occurrences") { + t.Errorf("Expected multiple occurrences to be renamed but got: %s", result) + } - // Also check that it was renamed in the consumer file - consumerContent, err := suite.ReadFile("consumer.go") - if err != nil { - t.Fatalf("Failed to read consumer.go: %v", err) - } + common.SnapshotTest(t, "go", snapshotCategory, "successful", result) - if !strings.Contains(consumerContent, "UpdatedConstant") { - t.Errorf("Expected to find renamed constant 'UpdatedConstant' in consumer.go") - } - }) + fileContent, err := suite.ReadFile("types.go") + if err != nil { + t.Fatalf("Failed to read types.go: %v", err) + } - // Test with a symbol that doesn't exist - t.Run("SymbolNotFound", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) + if !strings.Contains(fileContent, "UpdatedConstant") { + t.Errorf("Expected to find renamed constant 'UpdatedConstant' in types.go") + } - // Wait for initialization - time.Sleep(2 * time.Second) + consumerContent, err := suite.ReadFile("consumer.go") + if err != nil { + t.Fatalf("Failed to read consumer.go: %v", err) + } - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() + if !strings.Contains(consumerContent, "UpdatedConstant") { + t.Errorf("Expected to find renamed constant 'UpdatedConstant' in consumer.go") + } + }) - // Ensure the file is open - filePath := filepath.Join(suite.WorkspaceDir, "clean.go") - err := suite.Client.OpenFile(ctx, filePath) - if err != nil { - t.Fatalf("Failed to open clean.go: %v", err) - } + t.Run("SymbolNotFound", func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) - // Request to rename a symbol at a position where no symbol exists - // The clean.go file doesn't have content at this position - _, err = tools.RenameSymbol(ctx, suite.Client, filePath, 10, 10, "NewName") + time.Sleep(2 * time.Second) - // Expect an error because there's no symbol at that position - if err == nil { - t.Errorf("Expected an error when renaming non-existent symbol, but got success") - } + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() - // Save the error message for the snapshot - errorMessage := err.Error() + filePath := filepath.Join(suite.WorkspaceDir, "clean.go") + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open clean.go: %v", err) + } - // Verify it mentions failing to rename - if !strings.Contains(errorMessage, "failed to rename") { - t.Errorf("Expected error message about failed rename but got: %s", errorMessage) - } + _, err = tools.RenameSymbol(ctx, suite.Client, filePath, 10, 10, "NewName") + + if err == nil { + t.Errorf("Expected an error when renaming non-existent symbol, but got success") + } + + errorMessage := err.Error() + + if !strings.Contains(errorMessage, "failed to rename") && !strings.Contains(errorMessage, "column is beyond") { + t.Errorf("Expected error message about failed rename but got: %s", errorMessage) + } - common.SnapshotTest(t, "go", "rename_symbol", "not_found", errorMessage) - }) + common.SnapshotTest(t, "go", snapshotCategory, "not_found", errorMessage) + }) + }) + } } diff --git a/integrationtests/tests/go/text_edit/text_edit_test.go b/integrationtests/tests/go/text_edit/text_edit_test.go index 38f3cdcc..c4c84dc9 100644 --- a/integrationtests/tests/go/text_edit/text_edit_test.go +++ b/integrationtests/tests/go/text_edit/text_edit_test.go @@ -12,17 +12,9 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestApplyTextEdits tests the ApplyTextEdits tool with various edit scenarios +// TestApplyTextEdits tests the ApplyTextEdits tool with various edit scenarios. +// Runs in both subprocess and headless (listen-mode) modes. func TestApplyTextEdits(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - // Create a test file with known content we can edit - testFileName := "edit_test.go" - testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) - initialContent := `package main import "fmt" @@ -41,12 +33,6 @@ func AnotherFunction() { } ` - // Write the test file using the suite's method to ensure proper handling - err := suite.WriteFile(testFileName, initialContent) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - tests := []struct { name string edits []tools.TextEdit @@ -163,55 +149,66 @@ func AnotherFunction() { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Reset the file before each test + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + snapshotCategory := "text_edit" + if mode.headless { + snapshotCategory = "text_edit_headless" + } + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + testFileName := "edit_test.go" + testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) + err := suite.WriteFile(testFileName, initialContent) if err != nil { - t.Fatalf("Failed to reset test file: %v", err) + t.Fatalf("Failed to create test file: %v", err) } - // Call the ApplyTextEdits tool with the non-URL file path - result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) - if err != nil { - t.Fatalf("Failed to apply text edits: %v", err) - } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + err := suite.WriteFile(testFileName, initialContent) + if err != nil { + t.Fatalf("Failed to reset test file: %v", err) + } - // Verify the result message - if !strings.Contains(result, "Successfully applied text edits") { - t.Errorf("Result does not contain success message: %s", result) - } + result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) + if err != nil { + t.Fatalf("Failed to apply text edits: %v", err) + } - // Read the file content after edits - content, err := suite.ReadFile(testFileName) - if err != nil { - t.Fatalf("Failed to read test file after edits: %v", err) - } + if !strings.Contains(result, "Successfully applied text edits") { + t.Errorf("Result does not contain success message: %s", result) + } - // Run all verification functions - for _, verify := range tc.verifications { - verify(t, content) - } + content, err := suite.ReadFile(testFileName) + if err != nil { + t.Fatalf("Failed to read test file after edits: %v", err) + } + + for _, verify := range tc.verifications { + verify(t, content) + } - // Use snapshot testing to verify the exact result - snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) - common.SnapshotTest(t, "go", "text_edit", snapshotName, result) + snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) + common.SnapshotTest(t, "go", snapshotCategory, snapshotName, result) + }) + } }) } } -// TestApplyTextEditsWithBorderCases tests edge cases for the ApplyTextEdits tool +// TestApplyTextEditsWithBorderCases tests edge cases for the ApplyTextEdits tool. +// Runs in both subprocess and headless (listen-mode) modes. func TestApplyTextEditsWithBorderCases(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - // Create a test file with known content we can edit - testFileName := "edge_case_test.go" - testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) - - initialContent := `package main + borderCasesContent := `package main import "fmt" @@ -228,12 +225,6 @@ func LastFunction() { } ` - // Write the test file using the suite's method - err := suite.WriteFile(testFileName, initialContent) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - tests := []struct { name string edits []tools.TextEdit @@ -311,39 +302,58 @@ func NewFunction() { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Reset the file before each test - err := suite.WriteFile(testFileName, initialContent) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + mode := mode + snapshotCategory := "text_edit" + if mode.headless { + snapshotCategory = "text_edit_headless" + } + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuiteForMode(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + testFileName := "edge_case_test.go" + testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) + + err := suite.WriteFile(testFileName, borderCasesContent) if err != nil { - t.Fatalf("Failed to reset test file: %v", err) + t.Fatalf("Failed to create test file: %v", err) } - // Call the ApplyTextEdits tool - result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) - if err != nil { - t.Fatalf("Failed to apply text edits: %v", err) - } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + err := suite.WriteFile(testFileName, borderCasesContent) + if err != nil { + t.Fatalf("Failed to reset test file: %v", err) + } - // Verify the result message - if !strings.Contains(result, "Successfully applied text edits") { - t.Errorf("Result does not contain success message: %s", result) - } + result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) + if err != nil { + t.Fatalf("Failed to apply text edits: %v", err) + } - // Read the file content after edits - content, err := suite.ReadFile(testFileName) - if err != nil { - t.Fatalf("Failed to read test file after edits: %v", err) - } + if !strings.Contains(result, "Successfully applied text edits") { + t.Errorf("Result does not contain success message: %s", result) + } - // Run all verification functions - for _, verify := range tc.verifications { - verify(t, content) - } + content, err := suite.ReadFile(testFileName) + if err != nil { + t.Fatalf("Failed to read test file after edits: %v", err) + } + + for _, verify := range tc.verifications { + verify(t, content) + } - // Use snapshot testing to verify the exact result - snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) - common.SnapshotTest(t, "go", "text_edit", snapshotName, result) + snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) + common.SnapshotTest(t, "go", snapshotCategory, snapshotName, result) + }) + } }) } } From 5c8116416bca09298f773c58428138aa67b612d0 Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sat, 7 Feb 2026 16:48:34 -0500 Subject: [PATCH 6/7] snapshots for headless Go integration tests --- .../go/diagnostics_headless/clean.snap | 1 + .../go/diagnostics_headless/dependency.snap | 10 ++++++++ .../go/diagnostics_headless/unreachable.snap | 10 ++++++++ .../snapshots/go/hover_headless/constant.snap | 12 ++++++++++ .../hover_headless/interface-method-impl.snap | 23 +++++++++++++++++++ .../go/hover_headless/interface-type.snap | 15 ++++++++++++ .../go/hover_headless/no-hover-info.snap | 2 ++ .../go/hover_headless/outside-file.snap | 1 + .../go/hover_headless/struct-method.snap | 23 +++++++++++++++++++ .../go/hover_headless/struct-type.snap | 23 +++++++++++++++++++ .../go/rename_symbol_headless/not_found.snap | 1 + .../go/rename_symbol_headless/successful.snap | 5 ++++ .../append_to_end_of_file.snap | 1 + .../go/text_edit_headless/delete_line.snap | 1 + .../edit_empty_function.snap | 1 + .../edit_single_line_function.snap | 1 + ...ng_it_and_including_original_content).snap | 1 + .../multiple_edits_in_same_file.snap | 1 + .../replace_multiple_lines.snap | 1 + .../replace_single_line.snap | 1 + 20 files changed, 134 insertions(+) create mode 100644 integrationtests/snapshots/go/diagnostics_headless/clean.snap create mode 100644 integrationtests/snapshots/go/diagnostics_headless/dependency.snap create mode 100644 integrationtests/snapshots/go/diagnostics_headless/unreachable.snap create mode 100644 integrationtests/snapshots/go/hover_headless/constant.snap create mode 100644 integrationtests/snapshots/go/hover_headless/interface-method-impl.snap create mode 100644 integrationtests/snapshots/go/hover_headless/interface-type.snap create mode 100644 integrationtests/snapshots/go/hover_headless/no-hover-info.snap create mode 100644 integrationtests/snapshots/go/hover_headless/outside-file.snap create mode 100644 integrationtests/snapshots/go/hover_headless/struct-method.snap create mode 100644 integrationtests/snapshots/go/hover_headless/struct-type.snap create mode 100644 integrationtests/snapshots/go/rename_symbol_headless/not_found.snap create mode 100644 integrationtests/snapshots/go/rename_symbol_headless/successful.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/delete_line.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap create mode 100644 integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap diff --git a/integrationtests/snapshots/go/diagnostics_headless/clean.snap b/integrationtests/snapshots/go/diagnostics_headless/clean.snap new file mode 100644 index 00000000..4842782c --- /dev/null +++ b/integrationtests/snapshots/go/diagnostics_headless/clean.snap @@ -0,0 +1 @@ +/TEST_OUTPUT/workspace/clean.go \ No newline at end of file diff --git a/integrationtests/snapshots/go/diagnostics_headless/dependency.snap b/integrationtests/snapshots/go/diagnostics_headless/dependency.snap new file mode 100644 index 00000000..d23a158e --- /dev/null +++ b/integrationtests/snapshots/go/diagnostics_headless/dependency.snap @@ -0,0 +1,10 @@ +/TEST_OUTPUT/workspace/consumer.go +Diagnostics in File: 1 +ERROR at L7:C28: not enough arguments in call to HelperFunction + have () + want (int) (Source: compiler, Code: WrongArgCount) + + 6|func ConsumerFunction() { + 7| message := HelperFunction() + 8| fmt.Println(message) + 9| diff --git a/integrationtests/snapshots/go/diagnostics_headless/unreachable.snap b/integrationtests/snapshots/go/diagnostics_headless/unreachable.snap new file mode 100644 index 00000000..69ad1ca6 --- /dev/null +++ b/integrationtests/snapshots/go/diagnostics_headless/unreachable.snap @@ -0,0 +1,10 @@ +/TEST_OUTPUT/workspace/main.go +Diagnostics in File: 2 +WARNING at L8:C2: unreachable code (Source: unreachable, Code: default) +ERROR at L9:C9: cannot use 3 (untyped int constant) as string value in return statement (Source: compiler, Code: IncompatibleAssign) + + 6|func FooBar() string { + 7| return "Hello, World!" + 8| fmt.Println("Unreachable code") // This is unreachable code + 9| return 3 +10|} diff --git a/integrationtests/snapshots/go/hover_headless/constant.snap b/integrationtests/snapshots/go/hover_headless/constant.snap new file mode 100644 index 00000000..911f7e9d --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/constant.snap @@ -0,0 +1,12 @@ +```go +const SharedConstant untyped string = "shared value" +``` + +--- + +SharedConstant is used in multiple files + + +--- + +[`main.SharedConstant` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedConstant) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/interface-method-impl.snap b/integrationtests/snapshots/go/hover_headless/interface-method-impl.snap new file mode 100644 index 00000000..df6f9527 --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/interface-method-impl.snap @@ -0,0 +1,23 @@ +```go +type SharedStruct struct { + ID int + Name string + Value float64 + Constants []string +} +``` + +--- + +SharedStruct is a struct used across multiple files + + +```go +func (s *SharedStruct) GetName() string +func (s *SharedStruct) Method() string +func (s *SharedStruct) Process() error +``` + +--- + +[`main.SharedStruct` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedStruct) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/interface-type.snap b/integrationtests/snapshots/go/hover_headless/interface-type.snap new file mode 100644 index 00000000..8604fd4c --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/interface-type.snap @@ -0,0 +1,15 @@ +```go +type SharedInterface interface { // size=16 (0x10) + Process() error + GetName() string +} +``` + +--- + +SharedInterface defines behavior implemented across files + + +--- + +[`main.SharedInterface` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedInterface) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/no-hover-info.snap b/integrationtests/snapshots/go/hover_headless/no-hover-info.snap new file mode 100644 index 00000000..40f321c7 --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/no-hover-info.snap @@ -0,0 +1,2 @@ +No hover information available for this position on the following line: +import "fmt" diff --git a/integrationtests/snapshots/go/hover_headless/outside-file.snap b/integrationtests/snapshots/go/hover_headless/outside-file.snap new file mode 100644 index 00000000..67eb0ec0 --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/outside-file.snap @@ -0,0 +1 @@ +failed to get hover information: request failed: line number 999 out of range 0-40 (code: 0) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/struct-method.snap b/integrationtests/snapshots/go/hover_headless/struct-method.snap new file mode 100644 index 00000000..df6f9527 --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/struct-method.snap @@ -0,0 +1,23 @@ +```go +type SharedStruct struct { + ID int + Name string + Value float64 + Constants []string +} +``` + +--- + +SharedStruct is a struct used across multiple files + + +```go +func (s *SharedStruct) GetName() string +func (s *SharedStruct) Method() string +func (s *SharedStruct) Process() error +``` + +--- + +[`main.SharedStruct` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedStruct) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/struct-type.snap b/integrationtests/snapshots/go/hover_headless/struct-type.snap new file mode 100644 index 00000000..34aa914f --- /dev/null +++ b/integrationtests/snapshots/go/hover_headless/struct-type.snap @@ -0,0 +1,23 @@ +```go +type SharedStruct struct { // size=56 (0x38), class=64 (0x40) + ID int + Name string + Value float64 + Constants []string +} +``` + +--- + +SharedStruct is a struct used across multiple files + + +```go +func (s *SharedStruct) GetName() string +func (s *SharedStruct) Method() string +func (s *SharedStruct) Process() error +``` + +--- + +[`main.SharedStruct` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedStruct) \ No newline at end of file diff --git a/integrationtests/snapshots/go/rename_symbol_headless/not_found.snap b/integrationtests/snapshots/go/rename_symbol_headless/not_found.snap new file mode 100644 index 00000000..0d99bd95 --- /dev/null +++ b/integrationtests/snapshots/go/rename_symbol_headless/not_found.snap @@ -0,0 +1 @@ +failed to rename symbol: request failed: column is beyond end of line (code: 0) \ No newline at end of file diff --git a/integrationtests/snapshots/go/rename_symbol_headless/successful.snap b/integrationtests/snapshots/go/rename_symbol_headless/successful.snap new file mode 100644 index 00000000..9bae4aaa --- /dev/null +++ b/integrationtests/snapshots/go/rename_symbol_headless/successful.snap @@ -0,0 +1,5 @@ +Successfully renamed symbol to 'UpdatedConstant'. +Updated 4 occurrences across 3 files: +/TEST_OUTPUT/workspace/another_consumer.go: L15:C23 +/TEST_OUTPUT/workspace/consumer.go: L15:C23 +/TEST_OUTPUT/workspace/types.go: L24:C4, L25:C7 diff --git a/integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap b/integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap new file mode 100644 index 00000000..434c338d --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap @@ -0,0 +1 @@ +Successfully applied text edits. 1 lines removed, 6 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/delete_line.snap b/integrationtests/snapshots/go/text_edit_headless/delete_line.snap new file mode 100644 index 00000000..0bc62793 --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/delete_line.snap @@ -0,0 +1 @@ +Successfully applied text edits. 1 lines removed, 0 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap b/integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap new file mode 100644 index 00000000..7e026ad7 --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap @@ -0,0 +1 @@ +Successfully applied text edits. 2 lines removed, 3 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap b/integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap new file mode 100644 index 00000000..87170b36 --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap @@ -0,0 +1 @@ +Successfully applied text edits. 1 lines removed, 3 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap b/integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap new file mode 100644 index 00000000..13051d6c --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap @@ -0,0 +1 @@ +Successfully applied text edits. 1 lines removed, 2 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap b/integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap new file mode 100644 index 00000000..11ce75e3 --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap @@ -0,0 +1 @@ +Successfully applied text edits. 2 lines removed, 2 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap b/integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap new file mode 100644 index 00000000..7579b699 --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap @@ -0,0 +1 @@ +Successfully applied text edits. 4 lines removed, 4 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap b/integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap new file mode 100644 index 00000000..030930c2 --- /dev/null +++ b/integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap @@ -0,0 +1 @@ +Successfully applied text edits. 1 lines removed, 1 lines added. \ No newline at end of file From 7886ee7fba9c38302d70882eedda79d3613aacbf Mon Sep 17 00:00:00 2001 From: Rick Batka Date: Sun, 8 Feb 2026 11:54:31 -0500 Subject: [PATCH 7/7] Fix go intergration tests --- AGENTS.md | 4 - .../snapshots/go/codelens/get.snap | 23 ++- .../go/definition_headless/constant.snap | 10 -- .../go/definition_headless/foobar.snap | 14 -- .../go/definition_headless/function.snap | 12 -- .../go/definition_headless/interface.snap | 12 -- .../go/definition_headless/method.snap | 12 -- .../go/definition_headless/not-found.snap | 1 - .../go/definition_headless/struct.snap | 13 -- .../go/definition_headless/type.snap | 10 -- .../go/definition_headless/variable.snap | 10 -- .../go/diagnostics_headless/clean.snap | 1 - .../go/diagnostics_headless/dependency.snap | 10 -- .../go/diagnostics_headless/unreachable.snap | 10 -- .../snapshots/go/hover_headless/constant.snap | 12 -- .../hover_headless/interface-method-impl.snap | 23 --- .../go/hover_headless/interface-type.snap | 15 -- .../go/hover_headless/no-hover-info.snap | 2 - .../go/hover_headless/outside-file.snap | 1 - .../go/hover_headless/struct-method.snap | 23 --- .../go/hover_headless/struct-type.snap | 23 --- .../references_headless/foobar-function.snap | 9 - .../references_headless/helper-function.snap | 28 ---- .../references_headless/interface-method.snap | 39 ----- .../go/references_headless/not-found.snap | 1 - .../references_headless/shared-constant.snap | 39 ----- .../references_headless/shared-interface.snap | 39 ----- .../go/references_headless/shared-struct.snap | 66 -------- .../go/references_headless/shared-type.snap | 35 ---- .../go/references_headless/struct-method.snap | 19 --- .../go/rename_symbol_headless/not_found.snap | 1 - .../go/rename_symbol_headless/successful.snap | 5 - .../append_to_end_of_file.snap | 1 - .../go/text_edit_headless/delete_line.snap | 1 - .../edit_empty_function.snap | 1 - .../edit_single_line_function.snap | 1 - ...ng_it_and_including_original_content).snap | 1 - .../multiple_edits_in_same_file.snap | 1 - .../replace_multiple_lines.snap | 1 - .../replace_single_line.snap | 1 - integrationtests/tests/common/framework.go | 22 +-- .../tests/go/codelens/codelens_test.go | 33 ++-- .../tests/go/definition/definition_test.go | 20 +-- .../diagnostics/diagnostics_headless_test.go | 157 ------------------ .../tests/go/diagnostics/diagnostics_test.go | 42 +++-- integrationtests/tests/go/hover/hover_test.go | 18 +- integrationtests/tests/go/internal/helpers.go | 44 +---- .../tests/go/references/references_test.go | 15 +- .../go/rename_symbol/rename_symbol_test.go | 33 +++- .../tests/go/text_edit/text_edit_test.go | 33 ++-- internal/lsp/client.go | 9 +- main.go | 7 +- 52 files changed, 165 insertions(+), 798 deletions(-) delete mode 100644 integrationtests/snapshots/go/definition_headless/constant.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/foobar.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/function.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/interface.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/method.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/not-found.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/struct.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/type.snap delete mode 100644 integrationtests/snapshots/go/definition_headless/variable.snap delete mode 100644 integrationtests/snapshots/go/diagnostics_headless/clean.snap delete mode 100644 integrationtests/snapshots/go/diagnostics_headless/dependency.snap delete mode 100644 integrationtests/snapshots/go/diagnostics_headless/unreachable.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/constant.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/interface-method-impl.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/interface-type.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/no-hover-info.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/outside-file.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/struct-method.snap delete mode 100644 integrationtests/snapshots/go/hover_headless/struct-type.snap delete mode 100644 integrationtests/snapshots/go/references_headless/foobar-function.snap delete mode 100644 integrationtests/snapshots/go/references_headless/helper-function.snap delete mode 100644 integrationtests/snapshots/go/references_headless/interface-method.snap delete mode 100644 integrationtests/snapshots/go/references_headless/not-found.snap delete mode 100644 integrationtests/snapshots/go/references_headless/shared-constant.snap delete mode 100644 integrationtests/snapshots/go/references_headless/shared-interface.snap delete mode 100644 integrationtests/snapshots/go/references_headless/shared-struct.snap delete mode 100644 integrationtests/snapshots/go/references_headless/shared-type.snap delete mode 100644 integrationtests/snapshots/go/references_headless/struct-method.snap delete mode 100644 integrationtests/snapshots/go/rename_symbol_headless/not_found.snap delete mode 100644 integrationtests/snapshots/go/rename_symbol_headless/successful.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/delete_line.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap delete mode 100644 integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap delete mode 100644 integrationtests/tests/go/diagnostics/diagnostics_headless_test.go diff --git a/AGENTS.md b/AGENTS.md index 53dc516a..283e48d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,6 @@ # Summary This project (mcp-language-server) is an MCP server that exposes language server protocol to AI agents. It helps MCP enabled clients (agents) navigate codebases more easily by giving them access semantic tools like get definition, references, rename, and diagnostics. -The project is mature and almost feature-complete, but we will be making some modifications to it. - -We will use Beads (bd) for issue tracking. - # Build go build -o mcp-language-server diff --git a/integrationtests/snapshots/go/codelens/get.snap b/integrationtests/snapshots/go/codelens/get.snap index b82dbfb0..af0861ca 100644 --- a/integrationtests/snapshots/go/codelens/get.snap +++ b/integrationtests/snapshots/go/codelens/get.snap @@ -5,35 +5,48 @@ Command: gopls.reset_go_mod_diagnostics Arguments: /TEST_OUTPUT/workspace/go.mod","DiagnosticSource":""} +{"source":"codelens"} [2] Location: Lines 1-1 + Title: Run govulncheck + Command: gopls.run_govulncheck + Arguments: +/TEST_OUTPUT/workspace/go.mod","Pattern":"./..."} +{"source":"codelens"} + +[3] Location: Lines 1-1 Title: Run go mod tidy Command: gopls.tidy Arguments: /TEST_OUTPUT/workspace/go.mod"]} +{"source":"codelens"} -[3] Location: Lines 1-1 +[4] Location: Lines 1-1 Title: Create vendor directory Command: gopls.vendor Arguments: /TEST_OUTPUT/workspace/go.mod"} +{"source":"codelens"} -[4] Location: Lines 5-5 +[5] Location: Lines 5-5 Title: Check for upgrades Command: gopls.check_upgrades Arguments: /TEST_OUTPUT/workspace/go.mod","Modules":["github.com/stretchr/testify"]} +{"source":"codelens"} -[5] Location: Lines 5-5 +[6] Location: Lines 5-5 Title: Upgrade transitive dependencies Command: gopls.upgrade_dependency Arguments: /TEST_OUTPUT/workspace/go.mod","GoCmdArgs":["-d","-u","-t","./..."],"AddRequire":false} +{"source":"codelens"} -[6] Location: Lines 5-5 +[7] Location: Lines 5-5 Title: Upgrade direct dependencies Command: gopls.upgrade_dependency Arguments: /TEST_OUTPUT/workspace/go.mod","GoCmdArgs":["-d","github.com/stretchr/testify"],"AddRequire":false} +{"source":"codelens"} -Found 6 code lens items. +Found 7 code lens items. diff --git a/integrationtests/snapshots/go/definition_headless/constant.snap b/integrationtests/snapshots/go/definition_headless/constant.snap deleted file mode 100644 index 35482d8d..00000000 --- a/integrationtests/snapshots/go/definition_headless/constant.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- - -Symbol: TestConstant -/TEST_OUTPUT/workspace/clean.go -Kind: Constant -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L25:C1 - L25:C38 - -25|const TestConstant = "constant value" - diff --git a/integrationtests/snapshots/go/definition_headless/foobar.snap b/integrationtests/snapshots/go/definition_headless/foobar.snap deleted file mode 100644 index d75e774f..00000000 --- a/integrationtests/snapshots/go/definition_headless/foobar.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- - -Symbol: FooBar -/TEST_OUTPUT/workspace/main.go -Kind: Function -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L6:C1 - L10:C2 - - 6|func FooBar() string { - 7| return "Hello, World!" - 8| fmt.Println("Unreachable code") // This is unreachable code - 9| return 3 -10|} - diff --git a/integrationtests/snapshots/go/definition_headless/function.snap b/integrationtests/snapshots/go/definition_headless/function.snap deleted file mode 100644 index e9b6b4af..00000000 --- a/integrationtests/snapshots/go/definition_headless/function.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- - -Symbol: TestFunction -/TEST_OUTPUT/workspace/clean.go -Kind: Function -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L31:C1 - L33:C2 - -31|func TestFunction() { -32| fmt.Println("This is a test function") -33|} - diff --git a/integrationtests/snapshots/go/definition_headless/interface.snap b/integrationtests/snapshots/go/definition_headless/interface.snap deleted file mode 100644 index aaddc556..00000000 --- a/integrationtests/snapshots/go/definition_headless/interface.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- - -Symbol: TestInterface -/TEST_OUTPUT/workspace/clean.go -Kind: Interface -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L17:C1 - L19:C2 - -17|type TestInterface interface { -18| DoSomething() error -19|} - diff --git a/integrationtests/snapshots/go/definition_headless/method.snap b/integrationtests/snapshots/go/definition_headless/method.snap deleted file mode 100644 index 7084befc..00000000 --- a/integrationtests/snapshots/go/definition_headless/method.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- - -Symbol: TestStruct.Method -/TEST_OUTPUT/workspace/clean.go -Kind: Method -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L12:C1 - L14:C2 - -12|func (t *TestStruct) Method() string { -13| return t.Name -14|} - diff --git a/integrationtests/snapshots/go/definition_headless/not-found.snap b/integrationtests/snapshots/go/definition_headless/not-found.snap deleted file mode 100644 index 1f0c9df0..00000000 --- a/integrationtests/snapshots/go/definition_headless/not-found.snap +++ /dev/null @@ -1 +0,0 @@ -NotFound not found \ No newline at end of file diff --git a/integrationtests/snapshots/go/definition_headless/struct.snap b/integrationtests/snapshots/go/definition_headless/struct.snap deleted file mode 100644 index 4fbb2e05..00000000 --- a/integrationtests/snapshots/go/definition_headless/struct.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- - -Symbol: TestStruct -/TEST_OUTPUT/workspace/clean.go -Kind: Struct -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L6:C1 - L9:C2 - - 6|type TestStruct struct { - 7| Name string - 8| Age int - 9|} - diff --git a/integrationtests/snapshots/go/definition_headless/type.snap b/integrationtests/snapshots/go/definition_headless/type.snap deleted file mode 100644 index 66849d21..00000000 --- a/integrationtests/snapshots/go/definition_headless/type.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- - -Symbol: TestType -/TEST_OUTPUT/workspace/clean.go -Kind: Class -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L22:C1 - L22:C21 - -22|type TestType string - diff --git a/integrationtests/snapshots/go/definition_headless/variable.snap b/integrationtests/snapshots/go/definition_headless/variable.snap deleted file mode 100644 index 030f41a1..00000000 --- a/integrationtests/snapshots/go/definition_headless/variable.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- - -Symbol: TestVariable -/TEST_OUTPUT/workspace/clean.go -Kind: Variable -Container Name: github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace -Range: L28:C1 - L28:C22 - -28|var TestVariable = 42 - diff --git a/integrationtests/snapshots/go/diagnostics_headless/clean.snap b/integrationtests/snapshots/go/diagnostics_headless/clean.snap deleted file mode 100644 index 4842782c..00000000 --- a/integrationtests/snapshots/go/diagnostics_headless/clean.snap +++ /dev/null @@ -1 +0,0 @@ -/TEST_OUTPUT/workspace/clean.go \ No newline at end of file diff --git a/integrationtests/snapshots/go/diagnostics_headless/dependency.snap b/integrationtests/snapshots/go/diagnostics_headless/dependency.snap deleted file mode 100644 index d23a158e..00000000 --- a/integrationtests/snapshots/go/diagnostics_headless/dependency.snap +++ /dev/null @@ -1,10 +0,0 @@ -/TEST_OUTPUT/workspace/consumer.go -Diagnostics in File: 1 -ERROR at L7:C28: not enough arguments in call to HelperFunction - have () - want (int) (Source: compiler, Code: WrongArgCount) - - 6|func ConsumerFunction() { - 7| message := HelperFunction() - 8| fmt.Println(message) - 9| diff --git a/integrationtests/snapshots/go/diagnostics_headless/unreachable.snap b/integrationtests/snapshots/go/diagnostics_headless/unreachable.snap deleted file mode 100644 index 69ad1ca6..00000000 --- a/integrationtests/snapshots/go/diagnostics_headless/unreachable.snap +++ /dev/null @@ -1,10 +0,0 @@ -/TEST_OUTPUT/workspace/main.go -Diagnostics in File: 2 -WARNING at L8:C2: unreachable code (Source: unreachable, Code: default) -ERROR at L9:C9: cannot use 3 (untyped int constant) as string value in return statement (Source: compiler, Code: IncompatibleAssign) - - 6|func FooBar() string { - 7| return "Hello, World!" - 8| fmt.Println("Unreachable code") // This is unreachable code - 9| return 3 -10|} diff --git a/integrationtests/snapshots/go/hover_headless/constant.snap b/integrationtests/snapshots/go/hover_headless/constant.snap deleted file mode 100644 index 911f7e9d..00000000 --- a/integrationtests/snapshots/go/hover_headless/constant.snap +++ /dev/null @@ -1,12 +0,0 @@ -```go -const SharedConstant untyped string = "shared value" -``` - ---- - -SharedConstant is used in multiple files - - ---- - -[`main.SharedConstant` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedConstant) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/interface-method-impl.snap b/integrationtests/snapshots/go/hover_headless/interface-method-impl.snap deleted file mode 100644 index df6f9527..00000000 --- a/integrationtests/snapshots/go/hover_headless/interface-method-impl.snap +++ /dev/null @@ -1,23 +0,0 @@ -```go -type SharedStruct struct { - ID int - Name string - Value float64 - Constants []string -} -``` - ---- - -SharedStruct is a struct used across multiple files - - -```go -func (s *SharedStruct) GetName() string -func (s *SharedStruct) Method() string -func (s *SharedStruct) Process() error -``` - ---- - -[`main.SharedStruct` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedStruct) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/interface-type.snap b/integrationtests/snapshots/go/hover_headless/interface-type.snap deleted file mode 100644 index 8604fd4c..00000000 --- a/integrationtests/snapshots/go/hover_headless/interface-type.snap +++ /dev/null @@ -1,15 +0,0 @@ -```go -type SharedInterface interface { // size=16 (0x10) - Process() error - GetName() string -} -``` - ---- - -SharedInterface defines behavior implemented across files - - ---- - -[`main.SharedInterface` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedInterface) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/no-hover-info.snap b/integrationtests/snapshots/go/hover_headless/no-hover-info.snap deleted file mode 100644 index 40f321c7..00000000 --- a/integrationtests/snapshots/go/hover_headless/no-hover-info.snap +++ /dev/null @@ -1,2 +0,0 @@ -No hover information available for this position on the following line: -import "fmt" diff --git a/integrationtests/snapshots/go/hover_headless/outside-file.snap b/integrationtests/snapshots/go/hover_headless/outside-file.snap deleted file mode 100644 index 67eb0ec0..00000000 --- a/integrationtests/snapshots/go/hover_headless/outside-file.snap +++ /dev/null @@ -1 +0,0 @@ -failed to get hover information: request failed: line number 999 out of range 0-40 (code: 0) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/struct-method.snap b/integrationtests/snapshots/go/hover_headless/struct-method.snap deleted file mode 100644 index df6f9527..00000000 --- a/integrationtests/snapshots/go/hover_headless/struct-method.snap +++ /dev/null @@ -1,23 +0,0 @@ -```go -type SharedStruct struct { - ID int - Name string - Value float64 - Constants []string -} -``` - ---- - -SharedStruct is a struct used across multiple files - - -```go -func (s *SharedStruct) GetName() string -func (s *SharedStruct) Method() string -func (s *SharedStruct) Process() error -``` - ---- - -[`main.SharedStruct` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedStruct) \ No newline at end of file diff --git a/integrationtests/snapshots/go/hover_headless/struct-type.snap b/integrationtests/snapshots/go/hover_headless/struct-type.snap deleted file mode 100644 index 34aa914f..00000000 --- a/integrationtests/snapshots/go/hover_headless/struct-type.snap +++ /dev/null @@ -1,23 +0,0 @@ -```go -type SharedStruct struct { // size=56 (0x38), class=64 (0x40) - ID int - Name string - Value float64 - Constants []string -} -``` - ---- - -SharedStruct is a struct used across multiple files - - -```go -func (s *SharedStruct) GetName() string -func (s *SharedStruct) Method() string -func (s *SharedStruct) Process() error -``` - ---- - -[`main.SharedStruct` on pkg.go.dev](https://pkg.go.dev/github.com/isaacphi/mcp-language-server/integrationtests/test-output/go/workspace#SharedStruct) \ No newline at end of file diff --git a/integrationtests/snapshots/go/references_headless/foobar-function.snap b/integrationtests/snapshots/go/references_headless/foobar-function.snap deleted file mode 100644 index 3ca23eed..00000000 --- a/integrationtests/snapshots/go/references_headless/foobar-function.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/main.go -References in File: 1 -At: L13:C14 - -12|func main() { -13| fmt.Println(FooBar()) -14|} diff --git a/integrationtests/snapshots/go/references_headless/helper-function.snap b/integrationtests/snapshots/go/references_headless/helper-function.snap deleted file mode 100644 index fe7fcb49..00000000 --- a/integrationtests/snapshots/go/references_headless/helper-function.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/another_consumer.go -References in File: 1 -At: L8:C34 - - 6|func AnotherConsumer() { - 7| // Use helper function - 8| fmt.Println("Another message:", HelperFunction()) - 9| -10| // Create another SharedStruct instance -11| s := &SharedStruct{ -12| ID: 2, -13| Name: "another test", - ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L7:C13 - - 6|func ConsumerFunction() { - 7| message := HelperFunction() - 8| fmt.Println(message) - 9| -10| // Use shared struct -11| s := &SharedStruct{ -12| ID: 1, diff --git a/integrationtests/snapshots/go/references_headless/interface-method.snap b/integrationtests/snapshots/go/references_headless/interface-method.snap deleted file mode 100644 index f8d54cc9..00000000 --- a/integrationtests/snapshots/go/references_headless/interface-method.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/another_consumer.go -References in File: 1 -At: L19:C15 - -6|func AnotherConsumer() { -... -14| Value: 99.9, -15| Constants: []string{SharedConstant, "extra"}, -16| } -17| -18| // Use the struct methods -19| if name := s.GetName(); name != "" { -20| fmt.Println("Got name:", name) -21| } -22| -23| // Implement the interface with a custom type -24| type CustomImplementor struct { - ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L24:C20 - -6|func ConsumerFunction() { -... -19| fmt.Println(s.Method()) -20| s.Process() -21| -22| // Use shared interface -23| var iface SharedInterface = s -24| fmt.Println(iface.GetName()) -25| -26| // Use shared type -27| var t SharedType = 100 -28| fmt.Println(t) -29|} diff --git a/integrationtests/snapshots/go/references_headless/not-found.snap b/integrationtests/snapshots/go/references_headless/not-found.snap deleted file mode 100644 index fa0a5532..00000000 --- a/integrationtests/snapshots/go/references_headless/not-found.snap +++ /dev/null @@ -1 +0,0 @@ -No references found for symbol: NotFound \ No newline at end of file diff --git a/integrationtests/snapshots/go/references_headless/shared-constant.snap b/integrationtests/snapshots/go/references_headless/shared-constant.snap deleted file mode 100644 index a90fc9d6..00000000 --- a/integrationtests/snapshots/go/references_headless/shared-constant.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/another_consumer.go -References in File: 1 -At: L15:C23 - -6|func AnotherConsumer() { -... -10| // Create another SharedStruct instance -11| s := &SharedStruct{ -12| ID: 2, -13| Name: "another test", -14| Value: 99.9, -15| Constants: []string{SharedConstant, "extra"}, -16| } -17| -18| // Use the struct methods -19| if name := s.GetName(); name != "" { -20| fmt.Println("Got name:", name) - ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L15:C23 - -6|func ConsumerFunction() { -... -10| // Use shared struct -11| s := &SharedStruct{ -12| ID: 1, -13| Name: "test", -14| Value: 42.0, -15| Constants: []string{SharedConstant}, -16| } -17| -18| // Call methods on the struct -19| fmt.Println(s.Method()) -20| s.Process() diff --git a/integrationtests/snapshots/go/references_headless/shared-interface.snap b/integrationtests/snapshots/go/references_headless/shared-interface.snap deleted file mode 100644 index b3ac4694..00000000 --- a/integrationtests/snapshots/go/references_headless/shared-interface.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/another_consumer.go -References in File: 1 -At: L33:C12 - -6|func AnotherConsumer() { -... -28| custom := &CustomImplementor{ -29| SharedStruct: *s, -30| } -31| -32| // Custom type implements SharedInterface through embedding -33| var iface SharedInterface = custom -34| iface.Process() -35| -36| // Use shared type as a slice type -37| values := []SharedType{1, 2, 3} -38| for _, v := range values { - ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L23:C12 - -6|func ConsumerFunction() { -... -18| // Call methods on the struct -19| fmt.Println(s.Method()) -20| s.Process() -21| -22| // Use shared interface -23| var iface SharedInterface = s -24| fmt.Println(iface.GetName()) -25| -26| // Use shared type -27| var t SharedType = 100 -28| fmt.Println(t) diff --git a/integrationtests/snapshots/go/references_headless/shared-struct.snap b/integrationtests/snapshots/go/references_headless/shared-struct.snap deleted file mode 100644 index 4c281d59..00000000 --- a/integrationtests/snapshots/go/references_headless/shared-struct.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/another_consumer.go -References in File: 2 -At: L11:C8, L25:C3 - - 6|func AnotherConsumer() { - 7| // Use helper function - 8| fmt.Println("Another message:", HelperFunction()) - 9| -10| // Create another SharedStruct instance -11| s := &SharedStruct{ -12| ID: 2, -13| Name: "another test", -14| Value: 99.9, -15| Constants: []string{SharedConstant, "extra"}, -16| } -... -20| fmt.Println("Got name:", name) -21| } -22| -23| // Implement the interface with a custom type -24| type CustomImplementor struct { -25| SharedStruct -26| } -27| -28| custom := &CustomImplementor{ -29| SharedStruct: *s, -30| } - ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L11:C8 - - 6|func ConsumerFunction() { - 7| message := HelperFunction() - 8| fmt.Println(message) - 9| -10| // Use shared struct -11| s := &SharedStruct{ -12| ID: 1, -13| Name: "test", -14| Value: 42.0, -15| Constants: []string{SharedConstant}, -16| } - ---- - -/TEST_OUTPUT/workspace/types.go -References in File: 3 -At: L14:C10, L31:C10, L37:C10 - -14|func (s *SharedStruct) Method() string { -15| return s.Name -16|} -... -31|func (s *SharedStruct) Process() error { -32| fmt.Printf("Processing %s with ID %d\n", s.Name, s.ID) -33| return nil -34|} -... -37|func (s *SharedStruct) GetName() string { -38| return s.Name -39|} diff --git a/integrationtests/snapshots/go/references_headless/shared-type.snap b/integrationtests/snapshots/go/references_headless/shared-type.snap deleted file mode 100644 index 54544bf2..00000000 --- a/integrationtests/snapshots/go/references_headless/shared-type.snap +++ /dev/null @@ -1,35 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/another_consumer.go -References in File: 1 -At: L37:C14 - -6|func AnotherConsumer() { -... -32| // Custom type implements SharedInterface through embedding -33| var iface SharedInterface = custom -34| iface.Process() -35| -36| // Use shared type as a slice type -37| values := []SharedType{1, 2, 3} -38| for _, v := range values { -39| fmt.Println("Value:", v) -40| } -41|} - ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L27:C8 - -6|func ConsumerFunction() { -... -22| // Use shared interface -23| var iface SharedInterface = s -24| fmt.Println(iface.GetName()) -25| -26| // Use shared type -27| var t SharedType = 100 -28| fmt.Println(t) -29|} diff --git a/integrationtests/snapshots/go/references_headless/struct-method.snap b/integrationtests/snapshots/go/references_headless/struct-method.snap deleted file mode 100644 index 65278a25..00000000 --- a/integrationtests/snapshots/go/references_headless/struct-method.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- - -/TEST_OUTPUT/workspace/consumer.go -References in File: 1 -At: L19:C16 - -6|func ConsumerFunction() { -... -14| Value: 42.0, -15| Constants: []string{SharedConstant}, -16| } -17| -18| // Call methods on the struct -19| fmt.Println(s.Method()) -20| s.Process() -21| -22| // Use shared interface -23| var iface SharedInterface = s -24| fmt.Println(iface.GetName()) diff --git a/integrationtests/snapshots/go/rename_symbol_headless/not_found.snap b/integrationtests/snapshots/go/rename_symbol_headless/not_found.snap deleted file mode 100644 index 0d99bd95..00000000 --- a/integrationtests/snapshots/go/rename_symbol_headless/not_found.snap +++ /dev/null @@ -1 +0,0 @@ -failed to rename symbol: request failed: column is beyond end of line (code: 0) \ No newline at end of file diff --git a/integrationtests/snapshots/go/rename_symbol_headless/successful.snap b/integrationtests/snapshots/go/rename_symbol_headless/successful.snap deleted file mode 100644 index 9bae4aaa..00000000 --- a/integrationtests/snapshots/go/rename_symbol_headless/successful.snap +++ /dev/null @@ -1,5 +0,0 @@ -Successfully renamed symbol to 'UpdatedConstant'. -Updated 4 occurrences across 3 files: -/TEST_OUTPUT/workspace/another_consumer.go: L15:C23 -/TEST_OUTPUT/workspace/consumer.go: L15:C23 -/TEST_OUTPUT/workspace/types.go: L24:C4, L25:C7 diff --git a/integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap b/integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap deleted file mode 100644 index 434c338d..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/append_to_end_of_file.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 1 lines removed, 6 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/delete_line.snap b/integrationtests/snapshots/go/text_edit_headless/delete_line.snap deleted file mode 100644 index 0bc62793..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/delete_line.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 1 lines removed, 0 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap b/integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap deleted file mode 100644 index 7e026ad7..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/edit_empty_function.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 2 lines removed, 3 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap b/integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap deleted file mode 100644 index 87170b36..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/edit_single_line_function.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 1 lines removed, 3 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap b/integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap deleted file mode 100644 index 13051d6c..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/insert_at_a_line_(by_replacing_it_and_including_original_content).snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 1 lines removed, 2 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap b/integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap deleted file mode 100644 index 11ce75e3..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/multiple_edits_in_same_file.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 2 lines removed, 2 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap b/integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap deleted file mode 100644 index 7579b699..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/replace_multiple_lines.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 4 lines removed, 4 lines added. \ No newline at end of file diff --git a/integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap b/integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap deleted file mode 100644 index 030930c2..00000000 --- a/integrationtests/snapshots/go/text_edit_headless/replace_single_line.snap +++ /dev/null @@ -1 +0,0 @@ -Successfully applied text edits. 1 lines removed, 1 lines added. \ No newline at end of file diff --git a/integrationtests/tests/common/framework.go b/integrationtests/tests/common/framework.go index 09fb19cb..c74567fc 100644 --- a/integrationtests/tests/common/framework.go +++ b/integrationtests/tests/common/framework.go @@ -21,13 +21,13 @@ import ( // LSPTestConfig defines configuration for a language server test type LSPTestConfig struct { - Name string // Name of the language server - Command string // Command to run (ignored if ConnectAddr is set) - Args []string // Arguments (ignored if ConnectAddr is set) - ConnectAddr string // If set, connect to existing LSP at this address (headless) instead of starting Command - HeadlessListenArg string // If set, start LSP with this listen arg (e.g. "-listen=127.0.0.1:%d") and connect via NewClientHeadless - WorkspaceDir string // Template workspace directory - InitializeTimeMs int // Time to wait after initialization in ms + Name string // Name of the language server + Command string // Command to run (ignored if ConnectAddr is set) + Args []string // Arguments (ignored if ConnectAddr is set) + ConnectAddr string // If set, connect to existing LSP at this address (headless) instead of starting Command + HeadlessListenArg string // If set, start LSP with this listen arg (e.g. "-listen=127.0.0.1:6061") and connect via NewClientHeadless + WorkspaceDir string // Template workspace directory + InitializeTimeMs int // Time to wait after initialization in ms } // TestSuite contains everything needed for running integration tests @@ -44,8 +44,8 @@ type TestSuite struct { logFile string t *testing.T LanguageName string - headless bool // true when using ConnectAddr or HeadlessListenArg (affects cleanup) - headlessCmd *exec.Cmd // when we start LSP in listen mode, the process we started (for cleanup) + headless bool // true when using ConnectAddr or HeadlessListenArg (affects cleanup) + headlessCmd *exec.Cmd // when we start LSP in listen mode, the process we started (for cleanup) } // NewTestSuite creates a new test suite for the given language server @@ -64,7 +64,7 @@ func NewTestSuite(t *testing.T, config LSPTestConfig) *TestSuite { // startLSPInListenMode reserves a port, starts the LSP with the same Command/Args plus // HeadlessListenArg (with %d replaced by the port), and waits until the server accepts connections. // Caller must connect with NewClientHeadless(addr) and is responsible for killing the process on cleanup. -func (ts *TestSuite) startLSPInListenMode(workspaceDir string) (addr string, cmd *exec.Cmd, err error) { +func (ts *TestSuite) startLSPInListenMode() (addr string, cmd *exec.Cmd, err error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", nil, fmt.Errorf("failed to reserve port: %w", err) @@ -205,7 +205,7 @@ func (ts *TestSuite) Setup() error { var client *lsp.Client if ts.Config.HeadlessListenArg != "" { // Start LSP in listen mode (same Command/Args as NewClient), then connect via NewClientHeadless - addr, cmd, err := ts.startLSPInListenMode(workspaceDir) + addr, cmd, err := ts.startLSPInListenMode() if err != nil { return err } diff --git a/integrationtests/tests/go/codelens/codelens_test.go b/integrationtests/tests/go/codelens/codelens_test.go index 16afe2c2..b02a4375 100644 --- a/integrationtests/tests/go/codelens/codelens_test.go +++ b/integrationtests/tests/go/codelens/codelens_test.go @@ -3,6 +3,8 @@ package codelens_test import ( "context" "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" @@ -21,14 +23,10 @@ func TestCodeLens(t *testing.T) { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode - snapshotCategory := "codelens" - if mode.headless { - snapshotCategory = "codelens_headless" - } t.Run(mode.name, func(t *testing.T) { + // Test GetCodeLens with a file that should have codelenses t.Run("GetCodeLens", func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() @@ -55,11 +53,12 @@ func TestCodeLens(t *testing.T) { t.Errorf("Expected 'tidy' code lens but got: %s", result) } - common.SnapshotTest(t, "go", snapshotCategory, "get", result) + common.SnapshotTest(t, "go", "codelens", "get", result) }) + // Test ExecuteCodeLens by running the tidy codelens command t.Run("ExecuteCodeLens", func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) defer cancel() @@ -79,12 +78,22 @@ func TestCodeLens(t *testing.T) { if !strings.Contains(strings.ToLower(result), "tidy") { t.Fatalf("Expected 'tidy' code lens but none found: %s", result) } + // Use regex to find a number in square brackets followed by 'tidy' with no square brackets in between, + // this tells us which codelens index is the `go mod tidy`. + re := regexp.MustCompile(`\[(\d+)\][^\[\]]*tidy`) + match := re.FindStringSubmatch(result) + if len(match) < 2 { + t.Fatalf("Could not find code lens index for 'tidy': %s", result) + } + tidyIndex, err := strconv.Atoi(match[1]) + if err != nil { + t.Fatalf("Failed to parse code lens index for 'tidy': %v", err) + } - // Typically, the tidy lens should be index 2 (1-based) for gopls, but let's log for debugging t.Logf("Code lenses: %s", result) - // Execute the code lens (use index 2 which should be the tidy lens) - execResult, err := tools.ExecuteCodeLens(ctx, suite.Client, filePath, 2) + // Execute the code lens using the index we found for the tidy lens + execResult, err := tools.ExecuteCodeLens(ctx, suite.Client, filePath, tidyIndex) if err != nil { t.Fatalf("ExecuteCodeLens failed: %v", err) } @@ -105,7 +114,7 @@ func TestCodeLens(t *testing.T) { t.Errorf("Expected dependency to be removed, but it's still there:\n%s", updatedContent) } - common.SnapshotTest(t, "go", snapshotCategory, "execute", execResult) + common.SnapshotTest(t, "go", "codelens", "execute", execResult) }) }) } diff --git a/integrationtests/tests/go/definition/definition_test.go b/integrationtests/tests/go/definition/definition_test.go index d8f4adcf..99b6e7f1 100644 --- a/integrationtests/tests/go/definition/definition_test.go +++ b/integrationtests/tests/go/definition/definition_test.go @@ -80,33 +80,29 @@ func TestReadDefinition(t *testing.T) { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode + t.Run(mode.name, func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) defer cancel() - snapshotCategory := "definition" - if mode.headless { - snapshotCategory = "definition_headless" - } - for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { - // Call the ReadDefinition tool + // Call the ReadDefinition tool result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) if err != nil { t.Fatalf("Failed to read definition: %v", err) } - // Check that the result contains relevant information + // Check that the result contains relevant information if !strings.Contains(result, tc.expectedText) { t.Errorf("Definition does not contain expected text: %s", tc.expectedText) } - // Use snapshot testing to verify exact output - common.SnapshotTest(t, "go", "definition", tc.snapshotName, result) + // Use snapshot testing to verify exact output + common.SnapshotTest(t, "go", "definition", tc.snapshotName, result) + }) + } }) } } diff --git a/integrationtests/tests/go/diagnostics/diagnostics_headless_test.go b/integrationtests/tests/go/diagnostics/diagnostics_headless_test.go deleted file mode 100644 index 09c66aec..00000000 --- a/integrationtests/tests/go/diagnostics/diagnostics_headless_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package diagnostics_test - -import ( - "context" - "fmt" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" - "github.com/isaacphi/mcp-language-server/integrationtests/tests/go/internal" - "github.com/isaacphi/mcp-language-server/internal/protocol" - "github.com/isaacphi/mcp-language-server/internal/tools" -) - -// TestDiagnosticsHeadless runs the same diagnostics tests with gopls started in listen mode and connected via NewClientHeadless. -func TestDiagnosticsHeadless(t *testing.T) { - t.Run("CleanFile", func(t *testing.T) { - suite := internal.GetHeadlessTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - filePath := filepath.Join(suite.WorkspaceDir, "clean.go") - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } - - if !strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected no diagnostics but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics_headless", "clean", result) - }) - - t.Run("FileWithError", func(t *testing.T) { - suite := internal.GetHeadlessTestSuite(t) - - time.Sleep(2 * time.Second) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - filePath := filepath.Join(suite.WorkspaceDir, "main.go") - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } - - if strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected diagnostics but got none") - } - - if !strings.Contains(result, "unreachable") { - t.Errorf("Expected unreachable code error but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics_headless", "unreachable", result) - }) - - t.Run("FileDependency", func(t *testing.T) { - suite := internal.GetHeadlessTestSuite(t) - - time.Sleep(2 * time.Second) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") - consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") - - err := suite.Client.OpenFile(ctx, helperPath) - if err != nil { - t.Fatalf("Failed to open helper.go: %v", err) - } - - err = suite.Client.OpenFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to open consumer.go: %v", err) - } - - time.Sleep(2 * time.Second) - - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } - - if !strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected no diagnostics initially but got: %s", result) - } - - modifiedHelperContent := `package main - -// HelperFunction now requires an int parameter -func HelperFunction(value int) string { - return "hello world" -} -` - err = suite.WriteFile("helper.go", modifiedHelperContent) - if err != nil { - t.Fatalf("Failed to update helper.go: %v", err) - } - - helperURI := fmt.Sprintf("file://%s", helperPath) - - err = suite.Client.NotifyChange(ctx, helperPath) - if err != nil { - t.Fatalf("Failed to notify change to helper.go: %v", err) - } - - fileChangeParams := protocol.DidChangeWatchedFilesParams{ - Changes: []protocol.FileEvent{ - { - URI: protocol.DocumentUri(helperURI), - Type: protocol.FileChangeType(protocol.Changed), - }, - }, - } - - err = suite.Client.DidChangeWatchedFiles(ctx, fileChangeParams) - if err != nil { - t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) - } - - time.Sleep(3 * time.Second) - - err = suite.Client.CloseFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to close consumer.go: %v", err) - } - - err = suite.Client.OpenFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to reopen consumer.go: %v", err) - } - - time.Sleep(3 * time.Second) - - result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) - } - - if strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected diagnostics after dependency change but got none") - } - - if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { - t.Errorf("Expected error about wrong arguments but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics_headless", "dependency", result) - }) -} diff --git a/integrationtests/tests/go/diagnostics/diagnostics_test.go b/integrationtests/tests/go/diagnostics/diagnostics_test.go index cff47014..d14a9b1c 100644 --- a/integrationtests/tests/go/diagnostics/diagnostics_test.go +++ b/integrationtests/tests/go/diagnostics/diagnostics_test.go @@ -21,15 +21,10 @@ func TestDiagnostics(t *testing.T) { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode - snapshotCategory := "diagnostics" - if mode.headless { - snapshotCategory = "diagnostics_headless" - } t.Run(mode.name, func(t *testing.T) { t.Run("CleanFile", func(t *testing.T) { // Get a test suite with clean code - // suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() @@ -45,12 +40,15 @@ func TestDiagnostics(t *testing.T) { t.Errorf("Expected no diagnostics but got: %s", result) } - common.SnapshotTest(t, "go", snapshotCategory, "clean", result) + common.SnapshotTest(t, "go", "diagnostics", "clean", result) }) + // Test with a file containing an error t.Run("FileWithError", func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + // Get a test suite with code that contains errors + suite := internal.GetTestSuite(t, mode.headless) + // Wait for diagnostics to be generated time.Sleep(2 * time.Second) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) @@ -62,6 +60,7 @@ func TestDiagnostics(t *testing.T) { t.Fatalf("GetDiagnosticsForFile failed: %v", err) } + // Verify we have diagnostics about unreachable code if strings.Contains(result, "No diagnostics found") { t.Errorf("Expected diagnostics but got none") } @@ -70,17 +69,23 @@ func TestDiagnostics(t *testing.T) { t.Errorf("Expected unreachable code error but got: %s", result) } - common.SnapshotTest(t, "go", snapshotCategory, "unreachable", result) + common.SnapshotTest(t, "go", "diagnostics", "unreachable", result) }) + // Test file dependency: file A (helper.go) provides a function, + // file B (consumer.go) uses it, then modify A to break B t.Run("FileDependency", func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + // Wait for initial diagnostics to be generated time.Sleep(2 * time.Second) + // Verify consumer.go is clean initially ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() + // Ensure both helper.go and consumer.go are open in the LSP helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") @@ -94,17 +99,21 @@ func TestDiagnostics(t *testing.T) { t.Fatalf("Failed to open consumer.go: %v", err) } + // Wait for files to be processed time.Sleep(2 * time.Second) + // Get initial diagnostics for consumer.go result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) if err != nil { t.Fatalf("GetDiagnosticsForFile failed: %v", err) } + // Should have no diagnostics initially if !strings.Contains(result, "No diagnostics found") { t.Errorf("Expected no diagnostics initially but got: %s", result) } + // Now modify the helper function to cause an error in the consumer modifiedHelperContent := `package main // HelperFunction now requires an int parameter @@ -112,18 +121,23 @@ func HelperFunction(value int) string { return "hello world" } ` + // Write the modified content to the file err = suite.WriteFile("helper.go", modifiedHelperContent) if err != nil { t.Fatalf("Failed to update helper.go: %v", err) } + // Explicitly notify the LSP server about the change helperURI := fmt.Sprintf("file://%s", helperPath) + // Notify the LSP server about the file change err = suite.Client.NotifyChange(ctx, helperPath) if err != nil { t.Fatalf("Failed to notify change to helper.go: %v", err) } + // Also send a didChangeWatchedFiles notification for coverage + // This simulates what the watcher would do fileChangeParams := protocol.DidChangeWatchedFilesParams{ Changes: []protocol.FileEvent{ { @@ -138,8 +152,10 @@ func HelperFunction(value int) string { t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) } + // Wait for LSP to process the change time.Sleep(3 * time.Second) + // Force reopen the consumer file to ensure LSP reevaluates it err = suite.Client.CloseFile(ctx, consumerPath) if err != nil { t.Fatalf("Failed to close consumer.go: %v", err) @@ -150,22 +166,26 @@ func HelperFunction(value int) string { t.Fatalf("Failed to reopen consumer.go: %v", err) } + // Wait for diagnostics to be generated time.Sleep(3 * time.Second) + // Check diagnostics again on consumer file - should now have an error result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) if err != nil { t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) } + // Should have diagnostics now if strings.Contains(result, "No diagnostics found") { t.Errorf("Expected diagnostics after dependency change but got none") } + // Should contain an error about function arguments if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { t.Errorf("Expected error about wrong arguments but got: %s", result) } - common.SnapshotTest(t, "go", snapshotCategory, "dependency", result) + common.SnapshotTest(t, "go", "diagnostics", "dependency", result) }) }) } diff --git a/integrationtests/tests/go/hover/hover_test.go b/integrationtests/tests/go/hover/hover_test.go index 6b5b2315..a95a9e5a 100644 --- a/integrationtests/tests/go/hover/hover_test.go +++ b/integrationtests/tests/go/hover/hover_test.go @@ -89,19 +89,12 @@ func TestHover(t *testing.T) { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode t.Run(mode.name, func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() - snapshotCategory := "hover" - if mode.headless { - snapshotCategory = "hover_headless" - } - for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { filePath := filepath.Join(suite.WorkspaceDir, tt.file) err := suite.Client.OpenFile(ctx, filePath) @@ -109,23 +102,28 @@ func TestHover(t *testing.T) { t.Fatalf("Failed to open %s: %v", tt.file, err) } + // Get hover info result, err := tools.GetHoverInfo(ctx, suite.Client, filePath, tt.line, tt.column) if err != nil { + // For the "OutsideFile" test, we expect an error if tt.name == "OutsideFile" { - common.SnapshotTest(t, "go", snapshotCategory, tt.snapshotName, err.Error()) + // Create a snapshot even for error case + common.SnapshotTest(t, "go", "hover", tt.snapshotName, err.Error()) return } t.Fatalf("GetHoverInfo failed: %v", err) } + // Verify expected content if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) { t.Errorf("Expected hover info to contain %q but got: %s", tt.expectedText, result) } + // Verify unexpected content is absent if tt.unexpectedText != "" && strings.Contains(result, tt.unexpectedText) { t.Errorf("Expected hover info NOT to contain %q but it was found: %s", tt.unexpectedText, result) } - common.SnapshotTest(t, "go", snapshotCategory, tt.snapshotName, result) + common.SnapshotTest(t, "go", "hover", tt.snapshotName, result) }) } }) diff --git a/integrationtests/tests/go/internal/helpers.go b/integrationtests/tests/go/internal/helpers.go index fd20ba17..448904ff 100644 --- a/integrationtests/tests/go/internal/helpers.go +++ b/integrationtests/tests/go/internal/helpers.go @@ -8,8 +8,9 @@ import ( "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" ) -// GetTestSuite returns a test suite for Go language server tests (starts gopls as subprocess) -func GetTestSuite(t *testing.T) *common.TestSuite { +// GetTestSuite returns a test suite for Go language server tests (either starts gopls as subprocess, or connects to an LSP in headless mode) +func GetTestSuite(t *testing.T, headless bool) *common.TestSuite { + // Configure Go LSP repoRoot, err := filepath.Abs("../../../..") if err != nil { t.Fatalf("Failed to get repo root: %v", err) @@ -22,45 +23,18 @@ func GetTestSuite(t *testing.T) *common.TestSuite { WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), InitializeTimeMs: 2000, } - - suite := common.NewTestSuite(t, config) - if err := suite.Setup(); err != nil { - t.Fatalf("Failed to set up test suite: %v", err) - } - t.Cleanup(func() { suite.Cleanup() }) - return suite -} - -// GetTestSuiteForMode returns a test suite for the given mode. When headless is true, starts gopls in listen -// mode and connects via NewClientHeadless; otherwise starts gopls as a subprocess. -func GetTestSuiteForMode(t *testing.T, headless bool) *common.TestSuite { if headless { - return GetHeadlessTestSuite(t) - } - return GetTestSuite(t) -} - -// GetHeadlessTestSuite returns a test suite that starts gopls in listen mode (same Command/Args as GetTestSuite) -// and connects via NewClientHeadless. No external server or GOPLS_HEADLESS_ADDR is required. -func GetHeadlessTestSuite(t *testing.T) *common.TestSuite { - repoRoot, err := filepath.Abs("../../../..") - if err != nil { - t.Fatalf("Failed to get repo root: %v", err) - } - - config := common.LSPTestConfig{ - Name: "go", - Command: "gopls", - Args: []string{}, - HeadlessListenArg: "-listen=127.0.0.1:%d", - WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), - InitializeTimeMs: 2000, + config.HeadlessListenArg = "-listen=127.0.0.1:%d" // Port will be decided at test run time } + // Create a test suite suite := common.NewTestSuite(t, config) + + // Set up the suite if err := suite.Setup(); err != nil { - t.Fatalf("Failed to set up headless test suite: %v", err) + t.Fatalf("Failed to set up test suite: %v", err) } + // Register cleanup t.Cleanup(func() { suite.Cleanup() }) return suite } diff --git a/integrationtests/tests/go/references/references_test.go b/integrationtests/tests/go/references/references_test.go index 30ce3551..ab3f22da 100644 --- a/integrationtests/tests/go/references/references_test.go +++ b/integrationtests/tests/go/references/references_test.go @@ -90,33 +90,30 @@ func TestFindReferences(t *testing.T) { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode t.Run(mode.name, func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) defer cancel() - snapshotCategory := "references" - if mode.headless { - snapshotCategory = "references_headless" - } - for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { + // Call the FindReferences tool result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) if err != nil { t.Fatalf("Failed to find references: %v", err) } + // Check that the result contains relevant information if !strings.Contains(result, tc.expectedText) { t.Errorf("References do not contain expected text: %s", tc.expectedText) } + // Count how many different files are mentioned in the result fileCount := countFilesInResult(result) if fileCount < tc.expectedFiles { t.Errorf("Expected references in at least %d files, but found in %d files", tc.expectedFiles, fileCount) } - common.SnapshotTest(t, "go", snapshotCategory, tc.snapshotName, result) + // Use snapshot testing to verify exact output + common.SnapshotTest(t, "go", "references", tc.snapshotName, result) }) } }) diff --git a/integrationtests/tests/go/rename_symbol/rename_symbol_test.go b/integrationtests/tests/go/rename_symbol/rename_symbol_test.go index 205ddaca..3e482b06 100644 --- a/integrationtests/tests/go/rename_symbol/rename_symbol_test.go +++ b/integrationtests/tests/go/rename_symbol/rename_symbol_test.go @@ -19,41 +19,46 @@ func TestRenameSymbol(t *testing.T) { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode - snapshotCategory := "rename_symbol" - if mode.headless { - snapshotCategory = "rename_symbol_headless" - } + t.Run(mode.name, func(t *testing.T) { + // Test with a successful rename of a symbol that exists t.Run("SuccessfulRename", func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + // Wait for initialization time.Sleep(2 * time.Second) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() + // Ensure the file is open filePath := filepath.Join(suite.WorkspaceDir, "types.go") err := suite.Client.OpenFile(ctx, filePath) if err != nil { t.Fatalf("Failed to open types.go: %v", err) } + // Request to rename SharedConstant to UpdatedConstant at its definition + // The constant is defined at line 25, column 7 of types.go result, err := tools.RenameSymbol(ctx, suite.Client, filePath, 25, 7, "UpdatedConstant") if err != nil { t.Fatalf("RenameSymbol failed: %v", err) } + // Verify the constant was renamed if !strings.Contains(result, "Successfully renamed symbol") { t.Errorf("Expected success message but got: %s", result) } + // Verify it's mentioned that it renamed multiple occurrences if !strings.Contains(result, "occurrences") { t.Errorf("Expected multiple occurrences to be renamed but got: %s", result) } - common.SnapshotTest(t, "go", snapshotCategory, "successful", result) + common.SnapshotTest(t, "go", "rename_symbol", "successful", result) + // Verify that the rename worked by checking for the updated constant name in the file fileContent, err := suite.ReadFile("types.go") if err != nil { t.Fatalf("Failed to read types.go: %v", err) @@ -63,6 +68,7 @@ func TestRenameSymbol(t *testing.T) { t.Errorf("Expected to find renamed constant 'UpdatedConstant' in types.go") } + // Also check that it was renamed in the consumer file consumerContent, err := suite.ReadFile("consumer.go") if err != nil { t.Fatalf("Failed to read consumer.go: %v", err) @@ -73,33 +79,42 @@ func TestRenameSymbol(t *testing.T) { } }) + // Test with a symbol that doesn't exist t.Run("SymbolNotFound", func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + // Wait for initialization time.Sleep(2 * time.Second) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() + // Ensure the file is open filePath := filepath.Join(suite.WorkspaceDir, "clean.go") err := suite.Client.OpenFile(ctx, filePath) if err != nil { t.Fatalf("Failed to open clean.go: %v", err) } + // Request to rename a symbol at a position where no symbol exists + // The clean.go file doesn't have content at this position _, err = tools.RenameSymbol(ctx, suite.Client, filePath, 10, 10, "NewName") + // Expect an error because there's no symbol at that position if err == nil { t.Errorf("Expected an error when renaming non-existent symbol, but got success") } + // Save the error message for the snapshot errorMessage := err.Error() + // Verify it mentions failing to rename if !strings.Contains(errorMessage, "failed to rename") && !strings.Contains(errorMessage, "column is beyond") { t.Errorf("Expected error message about failed rename but got: %s", errorMessage) } - common.SnapshotTest(t, "go", snapshotCategory, "not_found", errorMessage) + common.SnapshotTest(t, "go", "rename_symbol", "not_found", errorMessage) }) }) } diff --git a/integrationtests/tests/go/text_edit/text_edit_test.go b/integrationtests/tests/go/text_edit/text_edit_test.go index c4c84dc9..137ade83 100644 --- a/integrationtests/tests/go/text_edit/text_edit_test.go +++ b/integrationtests/tests/go/text_edit/text_edit_test.go @@ -153,52 +153,53 @@ func AnotherFunction() { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode - snapshotCategory := "text_edit" - if mode.headless { - snapshotCategory = "text_edit_headless" - } + t.Run(mode.name, func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) defer cancel() testFileName := "edit_test.go" testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) + // Reset the file before each test err := suite.WriteFile(testFileName, initialContent) if err != nil { t.Fatalf("Failed to create test file: %v", err) } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { err := suite.WriteFile(testFileName, initialContent) if err != nil { t.Fatalf("Failed to reset test file: %v", err) } + // Call the ApplyTextEdits tool with the non-URL file path result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) if err != nil { t.Fatalf("Failed to apply text edits: %v", err) } + // Verify the result message if !strings.Contains(result, "Successfully applied text edits") { t.Errorf("Result does not contain success message: %s", result) } + // Read the file content after edits content, err := suite.ReadFile(testFileName) if err != nil { t.Fatalf("Failed to read test file after edits: %v", err) } + // Run all verification functions for _, verify := range tc.verifications { verify(t, content) } + // Use snapshot testing to verify the exact result snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) - common.SnapshotTest(t, "go", snapshotCategory, snapshotName, result) + common.SnapshotTest(t, "go", "text_edit", snapshotName, result) }) } }) @@ -306,52 +307,52 @@ func NewFunction() { name string headless bool }{{"Subprocess", false}, {"Headless", true}} { - mode := mode - snapshotCategory := "text_edit" - if mode.headless { - snapshotCategory = "text_edit_headless" - } t.Run(mode.name, func(t *testing.T) { - suite := internal.GetTestSuiteForMode(t, mode.headless) + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) defer cancel() testFileName := "edge_case_test.go" testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) + // Reset the file before each test err := suite.WriteFile(testFileName, borderCasesContent) if err != nil { t.Fatalf("Failed to create test file: %v", err) } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { err := suite.WriteFile(testFileName, borderCasesContent) if err != nil { t.Fatalf("Failed to reset test file: %v", err) } + // Call the ApplyTextEdits tool result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) if err != nil { t.Fatalf("Failed to apply text edits: %v", err) } + // Verify the result message if !strings.Contains(result, "Successfully applied text edits") { t.Errorf("Result does not contain success message: %s", result) } + // Read the file content after edits content, err := suite.ReadFile(testFileName) if err != nil { t.Fatalf("Failed to read test file after edits: %v", err) } + // Run all verification functions for _, verify := range tc.verifications { verify(t, content) } + // Use snapshot testing to verify the exact result snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) - common.SnapshotTest(t, "go", snapshotCategory, snapshotName, result) + common.SnapshotTest(t, "go", "text_edit", snapshotName, result) }) } }) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 57472a8e..859c1333 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -102,9 +102,9 @@ func NewClient(command string, args ...string) (*Client, error) { return client, nil } -// NewClientHeadless connects to an already-running LSP server at addr (e.g. "localhost:6060"). +// NewClientHeadless connects to an already-running LSP server at addr (e.g. "localhost:6061"). // The server must speak LSP JSON-RPC over the stream (Content-Length + JSON). No process is -// started and stderr is not read. For gopls, start the server with -listen=:6060 so it +// started and stderr is not read. For gopls, start the server with -listen=:6061 so it // accepts LSP connections on that port (--debug is for the debug HTTP server, not LSP). func NewClientHeadless(addr string) (*Client, error) { conn, err := net.Dial("tcp", addr) @@ -258,9 +258,11 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) ( } func (c *Client) Close() error { + // Try to close all open files first ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // Attempt to close files but continue shutdown regardless c.CloseAllFiles(ctx) if c.Cmd == nil { @@ -287,14 +289,17 @@ func (c *Client) Close() error { } close(forcedKill) case <-forcedKill: + // Channel closed from completion path return } }() + // Close stdin to signal the server if err := c.stdin.Close(); err != nil { lspLogger.Error("Failed to close stdin: %v", err) } + // Wait for process to exit err := c.Cmd.Wait() close(forcedKill) diff --git a/main.go b/main.go index 51b75d20..770a16b3 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ type config struct { workspaceDir string lspCommand string lspArgs []string - lspConnect string // if set, connect to existing LSP at this address (e.g. localhost:6060) instead of starting a process + lspConnect string // if set, connect to existing LSP at this address (e.g. localhost:6061) instead of starting a process } type mcpServer struct { @@ -41,7 +41,7 @@ func parseConfig() (*config, error) { cfg := &config{} flag.StringVar(&cfg.workspaceDir, "workspace", "", "Path to workspace directory") flag.StringVar(&cfg.lspCommand, "lsp", "", "LSP command to run (args should be passed after --)") - flag.StringVar(&cfg.lspConnect, "lsp-connect", "", "Connect to existing LSP at address (e.g. localhost:6060) instead of starting a process (for gopls use -listen=:PORT)") + flag.StringVar(&cfg.lspConnect, "lsp-connect", "", "Connect to existing LSP at address (e.g. localhost:6061) instead of starting a process (for gopls use -listen=:PORT)") flag.Parse() // Get remaining args after -- as LSP arguments @@ -225,9 +225,11 @@ func cleanup(s *mcpServer, done chan struct{}) { s.lspClient.CloseAllFiles(ctx) if !s.headless { + // Create a shorter timeout context for the shutdown request shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) defer shutdownCancel() + // Run shutdown in a goroutine with timeout to avoid blocking if LSP doesn't respond shutdownDone := make(chan struct{}) go func() { coreLogger.Info("Sending shutdown request") @@ -237,6 +239,7 @@ func cleanup(s *mcpServer, done chan struct{}) { close(shutdownDone) }() + // Wait for shutdown with timeout select { case <-shutdownDone: coreLogger.Info("Shutdown request completed")