feat(lsp): allow to set custom env to lsp servers via config (#778)

bbrodriges created

Change summary

README.md                 |  7 ++++-
internal/config/config.go | 49 ++++++++++++++++++++++++----------------
internal/lsp/client.go    |  4 ++
schema.json               |  9 ++++++
4 files changed, 45 insertions(+), 24 deletions(-)

Detailed changes

README.md 🔗

@@ -182,7 +182,10 @@ like you would. LSPs can be added manually like so:
   "$schema": "https://charm.land/crush.json",
   "lsp": {
     "go": {
-      "command": "gopls"
+      "command": "gopls",
+      "env": {
+        "GOTOOLCHAIN": "go1.24.5"
+      }
     },
     "typescript": {
       "command": "typescript-language-server",
@@ -433,7 +436,7 @@ accounts or OAuth workarounds, which may violate Anthropic and Microsoft’s
 Terms of Service.
 
 We’re committed to building sustainable, trusted integrations with model
-providers. If you’re a provider interested in working with us, 
+providers. If you’re a provider interested in working with us,
 [reach out](mailto:vt100@charm.sh).
 
 ## Logging

internal/config/config.go 🔗

@@ -117,11 +117,12 @@ type MCPConfig struct {
 }
 
 type LSPConfig struct {
-	Disabled  bool     `json:"enabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
-	Command   string   `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
-	Args      []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
-	Options   any      `json:"options,omitempty" jsonschema:"description=LSP server-specific configuration options"`
-	FileTypes []string `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"`
+	Disabled  bool              `json:"enabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
+	Command   string            `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
+	Args      []string          `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
+	Env       map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set to the LSP server command"`
+	Options   any               `json:"options,omitempty" jsonschema:"description=LSP server-specific configuration options"`
+	FileTypes []string          `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"`
 }
 
 type TUIOptions struct {
@@ -186,22 +187,12 @@ func (l LSPs) Sorted() []LSP {
 	return sorted
 }
 
-func (m MCPConfig) ResolvedEnv() []string {
-	resolver := NewShellVariableResolver(env.New())
-	for e, v := range m.Env {
-		var err error
-		m.Env[e], err = resolver.ResolveValue(v)
-		if err != nil {
-			slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
-			continue
-		}
-	}
+func (l LSPConfig) ResolvedEnv() []string {
+	return resolveEnvs(l.Env)
+}
 
-	env := make([]string, 0, len(m.Env))
-	for k, v := range m.Env {
-		env = append(env, fmt.Sprintf("%s=%s", k, v))
-	}
-	return env
+func (m MCPConfig) ResolvedEnv() []string {
+	return resolveEnvs(m.Env)
 }
 
 func (m MCPConfig) ResolvedHeaders() map[string]string {
@@ -509,3 +500,21 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 	_ = b.Body.Close()
 	return nil
 }
+
+func resolveEnvs(envs map[string]string) []string {
+	resolver := NewShellVariableResolver(env.New())
+	for e, v := range envs {
+		var err error
+		envs[e], err = resolver.ResolveValue(v)
+		if err != nil {
+			slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+			continue
+		}
+	}
+
+	res := make([]string, 0, len(envs))
+	for k, v := range envs {
+		res = append(res, fmt.Sprintf("%s=%s", k, v))
+	}
+	return res
+}

internal/lsp/client.go 🔗

@@ -11,6 +11,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"slices"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -66,8 +67,9 @@ type Client struct {
 // NewClient creates a new LSP client.
 func NewClient(ctx context.Context, name string, config config.LSPConfig) (*Client, error) {
 	cmd := exec.CommandContext(ctx, config.Command, config.Args...)
+
 	// Copy env
-	cmd.Env = os.Environ()
+	cmd.Env = slices.Concat(os.Environ(), config.ResolvedEnv())
 
 	stdin, err := cmd.StdinPipe()
 	if err != nil {

schema.json 🔗

@@ -63,6 +63,13 @@
           "type": "array",
           "description": "Arguments to pass to the LSP server command"
         },
+        "env": {
+          "additionalProperties": {
+            "type": "string"
+          },
+          "type": "object",
+          "description": "Environment variables to set to the LSP server command"
+        },
         "options": {
           "description": "LSP server-specific configuration options"
         },
@@ -426,4 +433,4 @@
       "type": "object"
     }
   }
-}
+}