diff --git a/pkg/files/service.go b/pkg/files/service.go index 5bbeba2..dd17391 100644 --- a/pkg/files/service.go +++ b/pkg/files/service.go @@ -19,7 +19,7 @@ import ( protocol "github.com/tliron/glsp/protocol_3_16" ) -const MaxDiagnosticsDelay = time.Second * 1 +const MaxDiagnosticsDelay = time.Second * 2 type Service interface { CreateFile(ctx context.Context, path, content string) (*model.File, error) diff --git a/pkg/lsp/v2/languages/adapter.go b/pkg/lsp/v2/languages/adapter.go index 8079644..cb07b0d 100644 --- a/pkg/lsp/v2/languages/adapter.go +++ b/pkg/lsp/v2/languages/adapter.go @@ -23,6 +23,9 @@ type Binary struct { // EnvAsKeyVal returns env vars in the form "key=value". func (b *Binary) EnvAsKeyVal() []string { + if len(b.Env) == 0 { + return nil + } // does not have a deterministic order out := make([]string, 0, len(b.Env)) for k, v := range b.Env { diff --git a/pkg/lsp/v2/languages/gopls.go b/pkg/lsp/v2/languages/gopls.go index 8d35f0c..ad0b591 100644 --- a/pkg/lsp/v2/languages/gopls.go +++ b/pkg/lsp/v2/languages/gopls.go @@ -80,9 +80,10 @@ func (a *gopls) FetchServerBinary(ctx context.Context, version interface{}, dele Name: a.Name(), Path: goplsPath, Arguments: []string{"serve"}, - Env: map[string]string{ - "GOPATH": os.Getenv("GOPATH"), - "GOROOT": os.Getenv("GOROOT"), + Env: map[string]string{ + // TODO: why this prevents LSP from working? + // "GOPATH": os.Getenv("GOPATH"), + // "GOROOT": os.Getenv("GOROOT"), }, }, nil } @@ -99,14 +100,15 @@ func (a *gopls) FetchServerBinary(ctx context.Context, version interface{}, dele Name: a.Name(), Path: goplsPath, Arguments: []string{"serve"}, - Env: map[string]string{ - "GOPATH": os.Getenv("GOPATH"), - "GOROOT": os.Getenv("GOROOT"), + Env: map[string]string{ + // TODO: why this prevents LSP from working? + //"GOPATH": os.Getenv("GOPATH"), + //"GOROOT": os.Getenv("GOROOT"), }, }, nil } -func checkVersion(ctx context.Context, path string, wantVersion string, delegate Delegate) (bool, error) { +func checkVersion(_ context.Context, path string, wantVersion string, _ Delegate) (bool, error) { if _, err := os.Stat(path); err != nil { return false, nil } diff --git a/pkg/lsp/v2/languages/languages.go b/pkg/lsp/v2/languages/languages.go index 9366b2c..8f0217b 100644 --- a/pkg/lsp/v2/languages/languages.go +++ b/pkg/lsp/v2/languages/languages.go @@ -9,8 +9,11 @@ const ( JavaScript LanguageID = "JavaScript" Python LanguageID = "Python" TypeScript LanguageID = "TypeScript" + // TODO: check that const is consistenet with go-entry + TSX LanguageID = "TSX" + JSX LanguageID = "JSX" ) var Adapters = []Adapter{ - new(gopls), + new(gopls), new(tsserver), } diff --git a/pkg/lsp/v2/languages/typescript_lsp.go b/pkg/lsp/v2/languages/typescript_lsp.go new file mode 100644 index 0000000..9ac0967 --- /dev/null +++ b/pkg/lsp/v2/languages/typescript_lsp.go @@ -0,0 +1,211 @@ +package lang + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/rs/zerolog/log" + protocol "github.com/tliron/glsp/protocol_3_16" +) + +var _ Adapter = (*tsserver)(nil) + +type tsserver struct{} + +type tsVersion struct { + Version string `json:"version"` +} + +// checkNodeRequirements verifies that Node.js and npm are available +func checkNodeRequirements(ctx context.Context) error { + // Check node version + nodeCmd := exec.CommandContext(ctx, "node", "--version") + output, err := nodeCmd.Output() + if err != nil { + return fmt.Errorf("node.js is required but not available: %w", err) + } + nodeVersion := strings.TrimSpace(string(output)) + + // Ensure minimum Node.js version (example: v14.0.0) + version := strings.TrimPrefix(nodeVersion, "v") + if !isVersionSufficient(version, "14.0.0") { + return fmt.Errorf("node.js version %s is too old, minimum required is 14.0.0", version) + } + log.Debug().Msgf("Found Node.js version: %s", nodeVersion) + + // Check npm version + npmBin := "npm" + if runtime.GOOS == "windows" { + npmBin += ".cmd" + } + npmCmd := exec.CommandContext(ctx, npmBin, "--version") + output, err = npmCmd.Output() + if err != nil { + return fmt.Errorf("npm is required but not available: %w", err) + } + npmVersion := strings.TrimSpace(string(output)) + log.Debug().Msgf("Found npm version: %s", npmVersion) + + return nil +} + +// Helper function to compare versions +func isVersionSufficient(current, minimum string) bool { + currentParts := strings.Split(current, ".") + minimumParts := strings.Split(minimum, ".") + + for i := 0; i < len(minimumParts); i++ { + if i >= len(currentParts) { + return false + } + if currentParts[i] < minimumParts[i] { + return false + } + if currentParts[i] > minimumParts[i] { + return true + } + } + return true +} + +func (a *tsserver) Name() ServerName { + return "typescript-language-server" +} + +func (a *tsserver) FetchServerBinary(ctx context.Context, version interface{}, delegate Delegate) (*Binary, error) { + if err := checkNodeRequirements(ctx); err != nil { + log.Error().Err(err).Msg("typescript-language-server: node requirements not met") + return nil, err + } + + var ver string + if v, ok := version.(tsVersion); ok { + ver = v.Version + } else { + ver = "latest" + } + + installDir, err := delegate.MakeInstallPath(ctx, "typescript-language-server", ver) + if err != nil { + log.Error().Err(err).Msgf("typescript-language-server: failed to make install path %s", ver) + return nil, err + } + + serverPath := filepath.Join(installDir, "node_modules", ".bin", "typescript-language-server") + if runtime.GOOS == "windows" { + serverPath += ".cmd" + } + + npmBin := "npm" + if runtime.GOOS == "windows" { + npmBin += ".cmd" + } + + if !delegate.Exist(ctx, serverPath) { + // Create a package.json if it doesn't exist + pkgJSON := map[string]interface{}{ + "name": "ts-lsp-install", + "version": "1.0.0", + "private": true, + } + pkgJSONBytes, _ := json.Marshal(pkgJSON) + pkgJSONPath := filepath.Join(installDir, "package.json") + if err := os.WriteFile(pkgJSONPath, pkgJSONBytes, 0644); err != nil { + return nil, fmt.Errorf("failed to create package.json: %w", err) + } + + // Install with all dependencies + cmd := exec.CommandContext(ctx, npmBin, "install", "--save", + "typescript-language-server@"+ver, + "typescript@latest", + "@typescript/vfs@latest", // Virtual file system support + "@typescript-eslint/typescript-estree@latest", // For parsing + ) + cmd.Dir = installDir // Set working directory to installDir + if output, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("failed to install typescript-language-server: %s: %w", string(output), err) + } + } + + env := map[string]string{ + "NODE_PATH": filepath.Join(installDir, "node_modules"), + "PATH": os.Getenv("PATH"), + // Enable logging for debugging + "TSS_LOG": "-level verbose -file " + filepath.Join(installDir, "tsserver.log"), + "DEBUG": "typescript-language-server:*", + } + + return &Binary{ + Name: a.Name(), + Path: serverPath, + Arguments: []string{ + "--stdio", // Use stdio for communication + "--log-level", "4", // Enable debug logging + }, + Env: env, + }, nil +} + +func (a *tsserver) FetchLatestServerVersion(ctx context.Context, delegate Delegate) (interface{}, error) { + body, err := delegate.Get(ctx, "https://registry.npmjs.org/typescript-language-server/latest") + if err != nil { + log.Error().Err(err).Msg("typescript-language-server: failed to get version") + return nil, err + } + + var version tsVersion + if err := json.Unmarshal(body, &version); err != nil { + log.Error().Err(err).Msg("typescript-language-server: failed to unmarshal version") + return nil, err + } + + log.Debug().Msgf("typescript-language-server: got version %+v", version) + return version, nil +} + +func (a *tsserver) InitializationOptions(ctx context.Context, delegate Delegate) json.RawMessage { + options := map[string]interface{}{ + "documentSymbols": true, // Enable document outline/symbols + "hierarchicalDocumentSymbolSupport": true, // Enable hierarchical symbols + "diagnostics": true, // Enable diagnostics + "completions": true, // Enable completions + "codeActions": true, // Enable code actions + "hover": true, // Enable hover + "implementation": true, // Enable go to implementation + "references": true, // Enable find references + "definition": true, // Enable go to definition + "tsserver": map[string]interface{}{ + "maxTsServerMemory": 4096, + "enableProjectDiagnostics": true, + }, + } + + out, _ := json.Marshal(options) + return out +} + +func (a *tsserver) WorkspaceConfiguration(ctx context.Context, delegate Delegate) (json.RawMessage, error) { + return nil, nil +} + +func (a *tsserver) CodeActions() ([]protocol.CodeActionKind, error) { + return []protocol.CodeActionKind{ + "quickfix", // Standard quick fixes + "refactor", // Generic refactoring actions + "refactor.extract", // Extract code to function/variable + "refactor.inline", // Inline code from function/variable + "refactor.rewrite", // Rewrite code structure + "source", // Source code modifications + "source.organizeImports", // Organize imports + }, nil +} + +func (a *tsserver) Languages() []LanguageID { + return []LanguageID{JavaScript, TypeScript, TSX, JSX} +} diff --git a/pkg/lsp/v2/process.go b/pkg/lsp/v2/process.go index a50b69d..a51c091 100644 --- a/pkg/lsp/v2/process.go +++ b/pkg/lsp/v2/process.go @@ -46,8 +46,7 @@ type ProcessImpl struct { func NewProcess(bin lang.Binary) (Process, error) { cmd := exec.Command(bin.Path, bin.Arguments...) - // TODO: check why this prevents LSP server from working - // cmd.Env = bin.EnvAsKeyVal() + cmd.Env = bin.EnvAsKeyVal() // Set SysProcAttr to create a new process group cmd.SysProcAttr = &syscall.SysProcAttr{ diff --git a/pkg/lsp/v2/runtime.go b/pkg/lsp/v2/runtime.go index 72fa75c..9f5f9ff 100644 --- a/pkg/lsp/v2/runtime.go +++ b/pkg/lsp/v2/runtime.go @@ -75,7 +75,7 @@ func (r *run) setupServer(ctx context.Context, adapter lang.Adapter, delegate la return err } - if err := r.registerSupport(srv, adapter); err != nil { + if err := r.registerSupport(adapter); err != nil { return err } @@ -140,7 +140,7 @@ func (r *run) registerBin(srv lang.ServerName, bin *lang.Binary) error { return nil } -func (r *run) registerSupport(srv lang.ServerName, adapter lang.Adapter) error { +func (r *run) registerSupport(adapter lang.Adapter) error { r.Lock() defer r.Unlock() diff --git a/pkg/lsp/v2/service.go b/pkg/lsp/v2/service.go index 8babf29..ed5bf3c 100644 --- a/pkg/lsp/v2/service.go +++ b/pkg/lsp/v2/service.go @@ -68,6 +68,9 @@ func (s *ServiceImpl) StartServer(ctx context.Context, languageId lang.LanguageI DocumentSymbol: &protocol.DocumentSymbolClientCapabilities{ HierarchicalDocumentSymbolSupport: boolPointer(true), }, + PublishDiagnostics: &protocol.PublishDiagnosticsClientCapabilities{ + RelatedInformation: boolPointer(true), + }, }, }, WorkspaceFolders: []protocol.WorkspaceFolder{ @@ -83,7 +86,7 @@ func (s *ServiceImpl) StartServer(ctx context.Context, languageId lang.LanguageI return fmt.Errorf("failed to initialize language server: %w", err) } - log.Debug().Str("languageId", languageId).Msg("Initialized language server") + log.Debug().Str("languageId", languageId).Any("initResult", initResult).Msg("Initialized language server") // Check capabilities if opt, ok := initResult.Capabilities.TextDocumentSync.(protocol.TextDocumentSyncOptions); ok { @@ -198,6 +201,8 @@ func (s *ServiceImpl) GetDocumentOutline(ctx context.Context, file model.File) ( return DocumentOutline{}, NewLanguageServerNotFoundError(lang) } + log.Debug().Msgf("get document outline %s", file.Path) + symbols, err := cli.GetDocumentSymbols(ctx, protocol.DocumentSymbolParams{ TextDocument: protocol.TextDocumentIdentifier{ URI: DocumentURI(file.Path), @@ -269,12 +274,12 @@ func (s *ServiceImpl) Cleanup(ctx context.Context) error { return nil } -func (s *ServiceImpl) getClient(ctx context.Context, languageId lang.LanguageID) (Client, bool) { +func (s *ServiceImpl) getClient(_ context.Context, languageId lang.LanguageID) (Client, bool) { client, ok := s.clientPool.Get(languageId) return client, ok } -func (s *ServiceImpl) getClients(ctx context.Context) []Client { +func (s *ServiceImpl) getClients(_ context.Context) []Client { clients := make([]Client, 0) for _, client := range s.clientPool.GetAll() { clients = append(clients, client) @@ -284,7 +289,7 @@ func (s *ServiceImpl) getClients(ctx context.Context) []Client { } func (s *ServiceImpl) listenForDiagnostics(channel <-chan protocol.PublishDiagnosticsParams) { - log.Debug().Msg("Start listening") + log.Debug().Msg("Start listening for diagnostics") // reads fro chanel util it is closed for diagnostics := range channel {