1// Package lsp implements a small, dependency-free LSP and linter backend.
2package lsp
3
4import (
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12)
13
14// ServerConfig describes either a stdio LSP server or a command-line linter.
15// Command is resolved relative to the workspace before it is started.
16type ServerConfig struct {
17 ID string `json:"-"`
18 Kind string `json:"kind,omitempty"` // "lsp" or "cli"
19 Command string `json:"command"`
20 Args []string `json:"args,omitempty"`
21 FileTypes []string `json:"fileTypes,omitempty"`
22 LanguageID string `json:"languageId,omitempty"`
23 RootMarkers []string `json:"rootMarkers,omitempty"`
24 IsLinter bool `json:"isLinter,omitempty"`
25 Disabled bool `json:"disabled,omitempty"`
26 Parser string `json:"parser,omitempty"`
27 Mode string `json:"mode,omitempty"` // files or workspace for CLI linters
28 RunOnStartup bool `json:"runOnStartup,omitempty"`
29 RunOnChange bool `json:"runOnChange,omitempty"`
30 Description string `json:"description,omitempty"`
31 Env map[string]string `json:"env,omitempty"`
32 Settings map[string]any `json:"settings,omitempty"`
33 InitializationOptions any `json:"initializationOptions,omitempty"`
34}
35
36// Config is the merged global/project LSP configuration.
37type Config struct {
38 Servers map[string]ServerConfig
39 AutoDetect bool
40 // Explicit contains servers named or defined by a config file. When
41 // auto-detection is disabled, only these configured providers remain
42 // eligible, matching the providers/autoDetect configuration behavior.
43 Explicit map[string]bool
44 Files []string
45}
46
47// BuiltinServers returns the built-in language servers. The slice is a copy
48// and may be modified by callers.
49func BuiltinServers() []ServerConfig {
50 return []ServerConfig{
51 {ID: "gopls", Kind: "lsp", Command: "gopls", FileTypes: []string{"go", ".go"}, RootMarkers: []string{"go.work", "go.mod"}, Description: "Go language server"},
52 {ID: "rust-analyzer", Kind: "lsp", Command: "rust-analyzer", FileTypes: []string{"rust", "rs", ".rs"}, RootMarkers: []string{"Cargo.toml"}, Description: "Rust language server"},
53 {ID: "typescript-language-server", Kind: "lsp", Command: "typescript-language-server", Args: []string{"--stdio"}, FileTypes: []string{"typescript", "typescriptreact", "javascript", "javascriptreact", "ts", "tsx", "js", "jsx", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}, RootMarkers: []string{"tsconfig.json", "jsconfig.json", "package.json"}, Description: "TypeScript and JavaScript language server"},
54 {ID: "pyright-langserver", Kind: "lsp", Command: "pyright-langserver", Args: []string{"--stdio"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"}, Description: "Python language server"},
55 {ID: "clangd", Kind: "lsp", Command: "clangd", FileTypes: []string{"c", "cpp", "c++", "h", "hpp", "cc", "cpp", "cxx", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"}, RootMarkers: []string{"compile_commands.json", "compile_flags.txt", "CMakeLists.txt"}, Description: "C and C++ language server"},
56 {ID: "yaml-language-server", Kind: "lsp", Command: "yaml-language-server", Args: []string{"--stdio"}, FileTypes: []string{"yaml", "yml", ".yaml", ".yml"}, Description: "YAML language server"},
57 {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"},
58 {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"},
59 {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"},
60 {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--output.json.path", "stdout"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"},
61 {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"},
62 }
63}
64
65// LoadConfig reads the global and project lsp.json files. Project files are
66// applied after the global file; within a project, lsp.json, .lsp.json, and
67// .zut/lsp.json are applied in that order. Missing files are harmless.
68func LoadConfig(cwd string) (Config, error) {
69 cwd, err := absDir(cwd)
70 if err != nil {
71 return Config{}, err
72 }
73 var files []string
74 if home := zutHome(); home != "" {
75 files = append(files, filepath.Join(home, "lsp.json"))
76 }
77 files = append(files,
78 filepath.Join(cwd, ".pi", "lsp.json"),
79 filepath.Join(cwd, ".omp", "lsp.json"),
80 filepath.Join(cwd, "lsp.json"),
81 filepath.Join(cwd, ".lsp.json"),
82 filepath.Join(cwd, ".zut", "lsp.json"),
83 )
84
85 objects := make(map[string]map[string]json.RawMessage)
86 explicit := make(map[string]bool)
87 autoDetect := true
88 for _, path := range files {
89 entries, exists, data, err := readConfigFile(path)
90 if err != nil {
91 return Config{}, err
92 }
93 if len(data) > 0 {
94 var metadata struct {
95 AutoDetect *bool `json:"autoDetect"`
96 }
97 if json.Unmarshal(data, &metadata) == nil && metadata.AutoDetect != nil {
98 autoDetect = *metadata.AutoDetect
99 }
100 }
101 if !exists {
102 continue
103 }
104 for id := range entries {
105 explicit[id] = true
106 }
107 for id := range providerIDs(data) {
108 explicit[id] = true
109 }
110 for id, entry := range entries {
111 if old := objects[id]; old != nil {
112 objects[id] = mergeRawObjects(old, entry)
113 } else {
114 objects[id] = cloneRawObject(entry)
115 }
116 }
117 }
118
119 merged := make(map[string]ServerConfig)
120 for _, server := range BuiltinServers() {
121 merged[server.ID] = server
122 }
123 for id, raw := range objects {
124 decodeRaw := raw
125 _, hasBuiltin := merged[id]
126 if base, ok := merged[id]; ok {
127 // Decode the already-merged JSON over the built-in by marshaling the
128 // built-in first. This retains fields that were omitted in the file.
129 baseRaw, _ := json.Marshal(base)
130 baseMap := map[string]json.RawMessage{}
131 _ = json.Unmarshal(baseRaw, &baseMap)
132 decodeRaw = mergeRawObjects(baseMap, raw)
133 }
134 server, err := decodeServer(id, decodeRaw)
135 if err != nil {
136 return Config{}, fmt.Errorf("lsp server %q: %w", id, err)
137 }
138 // A parser/mode is an explicit CLI declaration even when the
139 // overridden built-in was an LSP. This makes {"parser":"sarif"}
140 // sufficient for a linter override.
141 if hasBuiltin {
142 if _, hasKind := raw["kind"]; !hasKind {
143 if _, hasParser := raw["parser"]; hasParser {
144 server.Kind = "cli"
145 }
146 }
147 }
148 merged[id] = server
149 }
150 for id, server := range merged {
151 if server.ID == "" {
152 server.ID = id
153 merged[id] = server
154 }
155 }
156 return Config{Servers: merged, AutoDetect: autoDetect, Explicit: explicit, Files: files}, nil
157}
158
159// LoadServers is a convenience for callers that only need auto-detected
160// server configurations.
161func LoadServers(cwd string) ([]ServerConfig, error) {
162 config, err := LoadConfig(cwd)
163 if err != nil {
164 return nil, err
165 }
166 return config.EffectiveServers(cwd), nil
167}
168
169// EffectiveServers returns enabled servers which are relevant to any file in
170// the workspace. It is useful for status UIs; Manager uses ApplicableServers
171// for a particular file.
172func (c Config) EffectiveServers(cwd string) []ServerConfig {
173 var out []ServerConfig
174 for _, server := range c.Servers {
175 if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) {
176 continue
177 }
178 if len(server.FileTypes) == 0 && len(server.RootMarkers) == 0 {
179 out = append(out, server)
180 continue
181 }
182 if hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) {
183 out = append(out, server)
184 }
185 }
186 sortServers(out)
187 return out
188}
189
190// ApplicableServers selects servers for path. An empty path means the cwd.
191func (c Config) ApplicableServers(cwd, path string) []ServerConfig {
192 var out []ServerConfig
193 for _, server := range c.Servers {
194 if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) || !serverMatchesPath(cwd, path, server) {
195 continue
196 }
197 out = append(out, server)
198 }
199 sortServers(out)
200 return out
201}
202
203func serverMatchesPath(cwd, path string, server ServerConfig) bool {
204 if len(server.FileTypes) == 0 {
205 return len(server.RootMarkers) == 0 || hasRootMarker(cwd, server.RootMarkers)
206 }
207 if path != "" {
208 if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) {
209 return false
210 }
211 return fileTypeMatches(path, server.FileTypes)
212 }
213 return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes)
214}
215
216func hasMatchingFile(cwd string, types []string) bool {
217 normalizedTypes := normalizeFileTypes(types)
218 var found bool
219 _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error {
220 if err != nil {
221 return nil
222 }
223 if found {
224 return filepath.SkipAll
225 }
226 if entry.IsDir() {
227 if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") {
228 return filepath.SkipDir
229 }
230 return nil
231 }
232 found = fileTypeMatchesNormalized(path, normalizedTypes)
233 if found {
234 return filepath.SkipAll
235 }
236 return nil
237 })
238 return found
239}
240
241func fileTypeMatches(path string, types []string) bool {
242 return fileTypeMatchesNormalized(path, normalizeFileTypes(types))
243}
244
245func normalizeFileTypes(types []string) []string {
246 normalized := make([]string, 0, len(types))
247 for _, typ := range types {
248 normalized = append(normalized, strings.ToLower(strings.TrimSpace(typ)))
249 }
250 return normalized
251}
252
253func fileTypeMatchesNormalized(path string, types []string) bool {
254 ext := strings.ToLower(filepath.Ext(path))
255 base := strings.ToLower(filepath.Base(path))
256 lang := languageForExtension(ext)
257 for _, typ := range types {
258 if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") {
259 return true
260 }
261 if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) {
262 return true
263 }
264 }
265 return false
266}
267
268func hasRootMarker(cwd string, markers []string) bool {
269 if len(markers) == 0 {
270 return false
271 }
272 cwd, _ = absDir(cwd)
273 for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
274 for _, marker := range markers {
275 if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
276 return true
277 }
278 }
279 next := filepath.Dir(dir)
280 if next == dir {
281 break
282 }
283 }
284 return false
285}
286
287// FindRoot resolves a server's workspace root by walking up from cwd and
288// choosing the nearest directory containing one of its root markers.
289func FindRoot(cwd string, markers []string) string {
290 cwd, err := absDir(cwd)
291 if err != nil {
292 return cwd
293 }
294 for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
295 for _, marker := range markers {
296 if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
297 return dir
298 }
299 }
300 next := filepath.Dir(dir)
301 if next == dir {
302 break
303 }
304 }
305 return cwd
306}
307
308func sortServers(servers []ServerConfig) {
309 rank := func(server ServerConfig) int {
310 if server.Kind == "lsp" && !server.IsLinter {
311 return 0
312 }
313 if server.Kind == "lsp" {
314 return 1
315 }
316 return 2
317 }
318 for i := 1; i < len(servers); i++ {
319 for j := i; j > 0; j-- {
320 left, right := servers[j-1], servers[j]
321 if rank(left) < rank(right) || (rank(left) == rank(right) && left.ID <= right.ID) {
322 break
323 }
324 servers[j-1], servers[j] = right, left
325 }
326 }
327}
328
329func absDir(cwd string) (string, error) {
330 if strings.TrimSpace(cwd) == "" {
331 cwd, _ = os.Getwd()
332 }
333 abs, err := filepath.Abs(cwd)
334 if err != nil {
335 return "", err
336 }
337 info, err := os.Stat(abs)
338 if err == nil && !info.IsDir() {
339 return "", fmt.Errorf("workspace %q is not a directory", cwd)
340 }
341 return filepath.Clean(abs), nil
342}
343
344func zutHome() string {
345 if value := strings.TrimSpace(os.Getenv("ZUT_HOME")); value != "" {
346 return value
347 }
348 if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" {
349 return filepath.Join(value, "zut")
350 }
351 switch runtime.GOOS {
352 case "darwin":
353 home, _ := os.UserHomeDir()
354 if home == "" {
355 return ""
356 }
357 return filepath.Join(home, "Library", "Application Support", "zut")
358 case "windows":
359 if value := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); value != "" {
360 return filepath.Join(value, "zut")
361 }
362 }
363 home, _ := os.UserHomeDir()
364 if home == "" {
365 return ""
366 }
367 return filepath.Join(home, ".local", "state", "zut")
368}
369
370func providerIDs(data []byte) map[string]struct{} {
371 ids := make(map[string]struct{})
372 var top struct {
373 Providers []json.RawMessage `json:"providers"`
374 }
375 if json.Unmarshal(data, &top) != nil {
376 return ids
377 }
378 for _, raw := range top.Providers {
379 var id string
380 if json.Unmarshal(raw, &id) == nil {
381 if id = strings.TrimSpace(id); id != "" {
382 ids[id] = struct{}{}
383 }
384 continue
385 }
386 var object struct {
387 ID string `json:"id"`
388 }
389 if json.Unmarshal(raw, &object) == nil {
390 if id = strings.TrimSpace(object.ID); id != "" {
391 ids[id] = struct{}{}
392 }
393 }
394 }
395 return ids
396}
397
398func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, []byte, error) {
399 data, err := os.ReadFile(path)
400 if errors.Is(err, os.ErrNotExist) {
401 return nil, false, nil, nil
402 }
403 if err != nil {
404 return nil, true, nil, err
405 }
406 var top map[string]json.RawMessage
407 if err := json.Unmarshal(data, &top); err != nil {
408 return nil, true, data, fmt.Errorf("read %s: %w", path, err)
409 }
410 root := top
411 if raw, ok := top["providers"]; ok {
412 // A providers array may contain compact string entries. Preserve
413 // them (built-ins are already in the default registry)
414 // and turn object entries into the map shape used internally.
415 var providers []json.RawMessage
416 if err := json.Unmarshal(raw, &providers); err != nil {
417 return nil, true, data, fmt.Errorf("%s.providers must be an array: %w", path, err)
418 }
419 root = make(map[string]json.RawMessage)
420 for index, provider := range providers {
421 var id string
422 if json.Unmarshal(provider, &id) == nil {
423 continue
424 }
425 var object map[string]json.RawMessage
426 if err := json.Unmarshal(provider, &object); err != nil {
427 return nil, true, data, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err)
428 }
429 idRaw, ok := object["id"]
430 if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" {
431 return nil, true, data, fmt.Errorf("%s.providers[%d] is missing id", path, index)
432 }
433 id = strings.TrimSpace(id)
434 delete(object, "id")
435 flattenProviderObject(object)
436 root[id] = mustJSON(object)
437 }
438 } else {
439 wrappedRoot := make(map[string]json.RawMessage)
440 wrapped := false
441 for _, key := range []string{"servers", "lspServers", "linters"} {
442 if raw, ok := top[key]; ok {
443 var section map[string]json.RawMessage
444 if err := json.Unmarshal(raw, §ion); err != nil {
445 return nil, true, data, fmt.Errorf("%s.%s must be an object: %w", path, key, err)
446 }
447 wrapped = true
448 for id, value := range section {
449 wrappedRoot[id] = value
450 }
451 }
452 }
453 if wrapped {
454 root = wrappedRoot
455 }
456 }
457 out := make(map[string]map[string]json.RawMessage)
458 for id, raw := range root {
459 if id == "$schema" || id == "servers" || id == "lspServers" || id == "linters" || id == "providers" || id == "autoDetect" || id == "version" || id == "debug" || id == "status" || id == "lifecycle" || id == "excludePaths" || id == "idleTimeoutMs" {
460 continue
461 }
462 var object map[string]json.RawMessage
463 if err := json.Unmarshal(raw, &object); err != nil {
464 // A string is a convenient shorthand for a command.
465 var command string
466 if json.Unmarshal(raw, &command) == nil {
467 object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))}
468 } else {
469 return nil, true, data, fmt.Errorf("%s.%s must be an object", path, id)
470 }
471 }
472 out[id] = object
473 }
474 return out, true, data, nil
475}
476
477func strconvQuote(s string) string {
478 b, _ := json.Marshal(s)
479 return string(b)
480}
481
482// flattenProviderObject adapts nested provider shapes to the flat
483// ServerConfig fields used by zut. Unknown provider metadata remains
484// harmless JSON and is ignored by decodeServer.
485func flattenProviderObject(object map[string]json.RawMessage) {
486 if _, exists := object["command"]; !exists {
487 var candidates []struct {
488 Command string `json:"command"`
489 Args []string `json:"args"`
490 }
491 if raw, ok := object["commandCandidates"]; ok && json.Unmarshal(raw, &candidates) == nil && len(candidates) > 0 && candidates[0].Command != "" {
492 object["command"] = mustJSON(candidates[0].Command)
493 if len(candidates[0].Args) > 0 {
494 object["args"] = mustJSON(candidates[0].Args)
495 }
496 }
497 }
498 if _, exists := object["kind"]; !exists {
499 if _, ok := object["cli"]; ok {
500 object["kind"] = mustJSON("cli")
501 } else if _, ok := object["lsp"]; ok {
502 object["kind"] = mustJSON("lsp")
503 }
504 }
505 if _, exists := object["fileTypes"]; !exists {
506 var selectors []struct {
507 LanguageID string `json:"languageId"`
508 Extensions []string `json:"extensions"`
509 Filenames []string `json:"filenames"`
510 }
511 if raw, ok := object["selectors"]; ok && json.Unmarshal(raw, &selectors) == nil {
512 var fileTypes []string
513 for _, selector := range selectors {
514 if selector.LanguageID != "" {
515 fileTypes = append(fileTypes, selector.LanguageID)
516 }
517 fileTypes = append(fileTypes, selector.Extensions...)
518 fileTypes = append(fileTypes, selector.Filenames...)
519 }
520 if len(fileTypes) > 0 {
521 object["fileTypes"] = mustJSON(uniqueStrings(fileTypes))
522 }
523 }
524 if _, exists := object["fileTypes"]; !exists {
525 var detect struct {
526 FileExtensions []string `json:"fileExtensionsAny"`
527 }
528 if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil && len(detect.FileExtensions) > 0 {
529 object["fileTypes"] = mustJSON(uniqueStrings(detect.FileExtensions))
530 }
531 }
532 }
533 if _, exists := object["rootMarkers"]; !exists {
534 var detect struct {
535 RepoFiles []string `json:"repoFilesAny"`
536 ConfigFiles []string `json:"configFilesAny"`
537 }
538 if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil {
539 markers := append(append([]string{}, detect.RepoFiles...), detect.ConfigFiles...)
540 if len(markers) > 0 {
541 object["rootMarkers"] = mustJSON(uniqueStrings(markers))
542 }
543 }
544 if _, exists := object["rootMarkers"]; !exists {
545 if raw, ok := object["configMarkers"]; ok {
546 object["rootMarkers"] = append(json.RawMessage(nil), raw...)
547 }
548 }
549 }
550 if raw, ok := object["lsp"]; ok {
551 var nested map[string]json.RawMessage
552 if json.Unmarshal(raw, &nested) == nil {
553 if _, exists := object["initializationOptions"]; !exists {
554 if value, ok := nested["initializationOptions"]; ok {
555 object["initializationOptions"] = append(json.RawMessage(nil), value...)
556 }
557 }
558 }
559 }
560 if raw, ok := object["cli"]; ok {
561 var nested map[string]json.RawMessage
562 if json.Unmarshal(raw, &nested) == nil {
563 for _, key := range []string{"parser", "mode", "runOnStartup", "runOnChange"} {
564 if _, exists := object[key]; exists {
565 continue
566 }
567 if value, ok := nested[key]; ok {
568 object[key] = append(json.RawMessage(nil), value...)
569 }
570 }
571 }
572 }
573}
574
575func uniqueStrings(values []string) []string {
576 seen := make(map[string]struct{}, len(values))
577 out := make([]string, 0, len(values))
578 for _, value := range values {
579 if strings.TrimSpace(value) == "" {
580 continue
581 }
582 if _, exists := seen[value]; exists {
583 continue
584 }
585 seen[value] = struct{}{}
586 out = append(out, value)
587 }
588 return out
589}
590
591func decodeServer(id string, raw map[string]json.RawMessage) (ServerConfig, error) {
592 if _, exists := raw["initializationOptions"]; !exists {
593 if value, ok := raw["initOptions"]; ok {
594 raw["initializationOptions"] = append(json.RawMessage(nil), value...)
595 }
596 }
597 if command, ok := raw["command"]; ok {
598 var list []string
599 if err := json.Unmarshal(command, &list); err == nil {
600 if len(list) == 0 {
601 return ServerConfig{}, fmt.Errorf("command array is empty")
602 }
603 raw["command"] = json.RawMessage(strconvQuote(list[0]))
604 if len(list) > 1 {
605 args, _ := json.Marshal(list[1:])
606 if _, exists := raw["args"]; !exists {
607 raw["args"] = args
608 }
609 }
610 }
611 }
612 data, err := json.Marshal(raw)
613 if err != nil {
614 return ServerConfig{}, err
615 }
616 var server ServerConfig
617 if err := json.Unmarshal(data, &server); err != nil {
618 return ServerConfig{}, err
619 }
620 server.ID = id
621 if server.Kind == "" {
622 if server.Parser != "" || server.Mode != "" {
623 server.Kind = "cli"
624 } else {
625 server.Kind = "lsp"
626 }
627 }
628 server.Kind = strings.ToLower(server.Kind)
629 if server.Kind != "lsp" && server.Kind != "cli" {
630 return ServerConfig{}, fmt.Errorf("kind must be lsp or cli")
631 }
632 if server.Kind == "cli" && server.Parser == "" {
633 server.Parser = "generic"
634 }
635 return server, nil
636}
637
638func cloneRawObject(in map[string]json.RawMessage) map[string]json.RawMessage {
639 out := make(map[string]json.RawMessage, len(in))
640 for key, value := range in {
641 out[key] = append(json.RawMessage(nil), value...)
642 }
643 return out
644}
645
646func mergeRawObjects(base, override map[string]json.RawMessage) map[string]json.RawMessage {
647 out := cloneRawObject(base)
648 for key, value := range override {
649 var baseObject, overrideObject map[string]json.RawMessage
650 if json.Unmarshal(out[key], &baseObject) == nil && json.Unmarshal(value, &overrideObject) == nil {
651 out[key] = mustJSON(mergeRawObjects(baseObject, overrideObject))
652 continue
653 }
654 out[key] = append(json.RawMessage(nil), value...)
655 }
656 return out
657}
658
659func mustJSON(value any) json.RawMessage {
660 data, _ := json.Marshal(value)
661 return data
662}
663
664func languageForExtension(ext string) string {
665 switch strings.ToLower(ext) {
666 case ".go":
667 return "go"
668 case ".rs":
669 return "rust"
670 case ".py":
671 return "python"
672 case ".ts":
673 return "typescript"
674 case ".tsx":
675 return "typescriptreact"
676 case ".js", ".mjs", ".cjs":
677 return "javascript"
678 case ".jsx":
679 return "javascriptreact"
680 case ".c", ".h":
681 return "c"
682 case ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx":
683 return "cpp"
684 case ".yaml", ".yml":
685 return "yaml"
686 case ".json", ".jsonc":
687 return "json"
688 case ".sh", ".bash", ".zsh":
689 return "shellscript"
690 default:
691 return ""
692 }
693}