v0.39.0 33c980b

config.go RawHistoryFolder

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, &section); 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close