163 lines
5.4 KiB
Go

package windsurf
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// ErrBinaryNotFound is returned when no Windsurf LS binary can be located
// via any configured source (env, explicit config, or platform candidates).
var ErrBinaryNotFound = fmt.Errorf("windsurf: language server binary not found")
// binaryStatFn reports whether the given path exists and is executable for
// the current platform. It is a package variable so tests can replace it
// with a map-backed implementation that does not touch the filesystem.
var binaryStatFn = defaultBinaryStat
// userHomeFn returns the user's home directory. Replaced in tests.
var userHomeFn = defaultUserHome
func defaultBinaryStat(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
if runtime.GOOS == "windows" {
// Windows ignores the Unix execute bit — rely on the .exe suffix.
return strings.HasSuffix(strings.ToLower(path), ".exe")
}
return info.Mode()&0o111 != 0
}
func defaultUserHome() string {
if dir, err := os.UserHomeDir(); err == nil {
return dir
}
return ""
}
// DiscoverBinary resolves the Windsurf LS binary path for the current
// platform. Resolution order:
//
// 1. LS_BINARY_PATH environment variable (explicit override — user intent
// wins even if the path doesn't exist, so we can surface a clear error)
// 2. cfg.Binary (explicit config override)
// 3. Platform-specific candidate list (official install locations)
//
// Returns ErrBinaryNotFound when none of the sources yield an executable
// file; the error message directs the user to LS_BINARY_PATH or ls_mode=docker.
func DiscoverBinary(cfg LSPoolConfig) (string, error) {
return discoverBinaryFor(DetectPlatform(), os.Getenv("LS_BINARY_PATH"), cfg.Binary)
}
func discoverBinaryFor(p Platform, envPath, cfgPath string) (string, error) {
if envPath != "" {
return validateBinaryPath(envPath, p, "LS_BINARY_PATH")
}
if cfgPath != "" {
return validateBinaryPath(cfgPath, p, "cfg.Binary")
}
candidates, err := platformCandidates(p)
if err != nil {
return "", err
}
for _, path := range candidates {
if binaryStatFn(path) {
return path, nil
}
}
return "", fmt.Errorf("%w for %s; searched %d paths (%s); set LS_BINARY_PATH or use ls_mode=docker",
ErrBinaryNotFound, p, len(candidates), strings.Join(candidates, ", "))
}
func validateBinaryPath(path string, p Platform, source string) (string, error) {
if binaryStatFn(path) {
return path, nil
}
hint := "file does not exist or is not executable"
if p.OS == "windows" && !strings.HasSuffix(strings.ToLower(path), ".exe") {
hint = "Windows LS binaries must end in .exe"
} else if p.OS != "windows" {
hint += " (try chmod +x)"
}
return "", fmt.Errorf("%w: %s=%q — %s; set LS_BINARY_PATH to a valid path or use ls_mode=docker",
ErrBinaryNotFound, source, path, hint)
}
// platformCandidates returns the ordered list of paths where the official
// Windsurf LS binary may be installed on the given platform. Paths are
// ordered by preference — the first existing+executable path wins.
func platformCandidates(p Platform) ([]string, error) {
filename, err := BinaryFilename(p)
if err != nil {
return nil, err
}
switch p.OS {
case "darwin":
return darwinCandidates(filename), nil
case "linux":
return linuxCandidates(filename), nil
case "windows":
return windowsCandidates(filename), nil
}
// BinaryFilename would have errored first, so this is defensive.
return nil, fmt.Errorf("%w: %s (no candidate list)", ErrUnsupportedPlatform, p)
}
func darwinCandidates(filename string) []string {
const bundleSubpath = "Contents/Resources/app/extensions/windsurf/bin"
candidates := []string{
filepath.Join("/Applications/Windsurf.app", bundleSubpath, filename),
}
if home := userHomeFn(); home != "" {
candidates = append(candidates,
filepath.Join(home, "Applications/Windsurf.app", bundleSubpath, filename),
)
}
// Legacy sub2api install (pre-cross-platform).
candidates = append(candidates, filepath.Join("/opt/windsurf", filename))
return candidates
}
func linuxCandidates(filename string) []string {
candidates := []string{
// Legacy sub2api install (pre-cross-platform) — matches existing deployments.
filepath.Join("/opt/windsurf", filename),
// Official Debian/RPM install locations.
filepath.Join("/usr/share/windsurf/resources/app/extensions/windsurf/bin", filename),
filepath.Join("/usr/lib/windsurf/resources/app/extensions/windsurf/bin", filename),
}
// User-local install (Flatpak/AppImage unpacked).
if home := userHomeFn(); home != "" {
candidates = append(candidates,
filepath.Join(home, ".local/share/windsurf/resources/app/extensions/windsurf/bin", filename),
)
}
return candidates
}
func windowsCandidates(filename string) []string {
// Split the install subpath into its components so filepath.Join produces
// a platform-native path on whichever OS this runs (Windows '\\', Unix '/').
// This matters for tests running on non-Windows builders.
installSubpath := []string{"resources", "app", "extensions", "windsurf", "bin", filename}
var candidates []string
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
candidates = append(candidates,
filepath.Join(append([]string{localAppData, "Programs", "Windsurf"}, installSubpath...)...),
)
}
if programFiles := os.Getenv("PROGRAMFILES"); programFiles != "" {
candidates = append(candidates,
filepath.Join(append([]string{programFiles, "Windsurf"}, installSubpath...)...),
)
}
return candidates
}