| // Copyright 2020 The Go Authors. All rights reserved. |
| // Licensed under the MIT License. |
| // See LICENSE in the project root for license information. |
| |
| package goplssetting |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "maps" |
| "os" |
| "os/exec" |
| "slices" |
| "sort" |
| "strconv" |
| "strings" |
| ) |
| |
| // Generate reads package.json and updates the gopls settings section |
| // based on `gopls api-json` output. This function requires `jq` to |
| // manipulate package.json. |
| func Generate(inputFile string, skipCleanup bool) ([]byte, error) { |
| if _, err := os.Stat(inputFile); err != nil { |
| return nil, err |
| } |
| |
| if _, err := exec.LookPath("jq"); err != nil { |
| return nil, fmt.Errorf("missing `jq`: %w", err) |
| } |
| |
| workDir, err := ioutil.TempDir("", "goplssettings") |
| if err != nil { |
| return nil, err |
| } |
| log.Printf("WORK=%v", workDir) |
| |
| if !skipCleanup { |
| defer os.RemoveAll(workDir) |
| } |
| |
| api, err := readGoplsAPI() |
| if err != nil { |
| return nil, err |
| } |
| options, err := extractOptions(api) |
| if err != nil { |
| return nil, err |
| } |
| b, err := asVSCodeSettingsJSON(options) |
| if err != nil { |
| return nil, err |
| } |
| f, err := ioutil.TempFile(workDir, "gopls.settings") |
| if err != nil { |
| return nil, err |
| } |
| if _, err := f.Write(b); err != nil { |
| return nil, err |
| } |
| if err := f.Close(); err != nil { |
| return nil, err |
| } |
| |
| return rewritePackageJSON(f.Name(), inputFile) |
| } |
| |
| // readGoplsAPI returns the output of `gopls api-json`. |
| func readGoplsAPI() (*API, error) { |
| version, err := exec.Command("gopls", "-v", "version").Output() |
| if err != nil { |
| return nil, fmt.Errorf("failed to check gopls version: %v", err) |
| } |
| log.Printf("Reading settings of gopls....\nversion:\n%s\n", version) |
| |
| out, err := exec.Command("gopls", "api-json").Output() |
| if err != nil { |
| return nil, fmt.Errorf("failed to run gopls: %v", err) |
| } |
| |
| api := &API{} |
| if err := json.Unmarshal(out, api); err != nil { |
| return nil, fmt.Errorf("failed to unmarshal: %v", err) |
| } |
| return api, nil |
| } |
| |
| // extractOptions extracts the options from APIJSON. |
| // It may rearrange the ordering and documentation for better presentation. |
| func extractOptions(api *API) ([]*Option, error) { |
| type sortableOptionJSON struct { |
| *Option |
| section string |
| } |
| options := []sortableOptionJSON{} |
| for k, v := range api.Options { |
| for _, o := range v { |
| options = append(options, sortableOptionJSON{Option: o, section: k}) |
| } |
| } |
| sort.SliceStable(options, func(i, j int) bool { |
| pi := priority(options[i].Option) |
| pj := priority(options[j].Option) |
| if pi == pj { |
| return options[i].Name < options[j].Name |
| } |
| return pi < pj |
| }) |
| |
| opts := []*Option{} |
| for _, v := range options { |
| if name := statusName(v.Option.Status); name != "" { |
| v.Option.Doc = name + " " + v.Option.Doc |
| } |
| // Enum keys or values can be marked with status individually. |
| for i, key := range v.EnumKeys.Keys { |
| if name := statusName(key.Status); name != "" { |
| v.EnumKeys.Keys[i].Doc = name + " " + key.Doc |
| } |
| } |
| for i, value := range v.EnumValues { |
| if name := statusName(value.Status); name != "" { |
| v.EnumValues[i].Doc = name + " " + value.Doc |
| } |
| } |
| opts = append(opts, v.Option) |
| } |
| return opts, nil |
| } |
| |
| func priority(opt *Option) int { |
| switch toStatus(opt.Status) { |
| case Experimental: |
| return 10 |
| case Debug: |
| return 100 |
| } |
| return 1000 |
| } |
| |
| func statusName(s string) string { |
| switch toStatus(s) { |
| case Experimental: |
| return "(Experimental)" |
| case Advanced: |
| return "(Advanced)" |
| case Debug: |
| return "(For Debugging)" |
| } |
| return "" |
| } |
| |
| func toStatus(s string) Status { |
| switch s { |
| case "experimental": |
| return Experimental |
| case "debug": |
| return Debug |
| case "advanced": |
| return Advanced |
| case "": |
| return None |
| default: |
| panic(fmt.Sprintf("unexpected status: %s", s)) |
| } |
| } |
| |
| // rewritePackageJSON rewrites the input package.json by running `jq` |
| // to update all existing gopls settings with the ones from the newSettings |
| // file. |
| func rewritePackageJSON(newSettings, inFile string) ([]byte, error) { |
| prog := `.contributes.configuration.properties+=$GOPLS_SETTINGS[0]` |
| cmd := exec.Command("jq", "--slurpfile", "GOPLS_SETTINGS", newSettings, prog, inFile) |
| var stdout, stderr bytes.Buffer |
| cmd.Stdout = &stdout |
| cmd.Stderr = &stderr |
| if err := cmd.Run(); err != nil { |
| return nil, fmt.Errorf("jq run failed (%v): %s", err, &stderr) |
| } |
| return bytes.TrimSpace(stdout.Bytes()), nil |
| } |
| |
| // asVSCodeSettingsJSON converts the given options to match the VS Code settings |
| // format. |
| func asVSCodeSettingsJSON(options []*Option) ([]byte, error) { |
| obj, err := asVSCodeSettings(options) |
| if err != nil { |
| return nil, err |
| } |
| return json.Marshal(obj) |
| } |
| |
| func asVSCodeSettings(options []*Option) (map[string]*Object, error) { |
| seen := map[string][]*Option{} |
| for _, opt := range options { |
| seen[opt.Hierarchy] = append(seen[opt.Hierarchy], opt) |
| } |
| for _, v := range seen { |
| sort.Slice(v, func(i, j int) bool { |
| return v[i].Name < v[j].Name |
| }) |
| } |
| goplsProperties, goProperties, err := collectProperties(seen) |
| if err != nil { |
| return nil, err |
| } |
| goProperties["gopls"] = &Object{ |
| Type: "object", |
| MarkdownDescription: "Configure the default Go language server ('gopls'). In most cases, configuring this section is unnecessary. See [the documentation](https://github.com/golang/tools/blob/master/gopls/doc/settings.md) for all available settings.", |
| Scope: "resource", |
| AdditionalProperties: false, |
| Properties: goplsProperties, |
| } |
| return goProperties, nil |
| } |
| |
| func collectProperties(m map[string][]*Option) (goplsProperties, goProperties map[string]*Object, err error) { |
| var sorted []string |
| var containsEmpty bool |
| for k := range m { |
| if k == "" { |
| containsEmpty = true |
| continue |
| } |
| sorted = append(sorted, k) |
| } |
| sort.Strings(sorted) |
| if containsEmpty { |
| sorted = append(sorted, "") |
| } |
| goplsProperties, goProperties = map[string]*Object{}, map[string]*Object{} |
| for _, hierarchy := range sorted { |
| if hierarchy == "ui.inlayhint" { |
| for _, opt := range m[hierarchy] { |
| for _, k := range opt.EnumKeys.Keys { |
| unquotedName, err := strconv.Unquote(k.Name) |
| if err != nil { |
| return nil, nil, err |
| } |
| key := "go.inlayHints." + unquotedName |
| goProperties[key] = &Object{ |
| MarkdownDescription: k.Doc, |
| Type: "boolean", |
| Default: formatDefault(k.Default, "boolean"), |
| } |
| } |
| } |
| continue |
| } |
| for _, opt := range m[hierarchy] { |
| obj, err := toObject(opt) |
| if err != nil { |
| return nil, nil, err |
| } |
| // TODO(hyangah): move diagnostic to all go.diagnostic. |
| if hierarchy == "ui.diagnostic" && opt.Name == "vulncheck" { |
| goProperties["go.diagnostic.vulncheck"] = obj |
| continue |
| } |
| key := opt.Name |
| if hierarchy != "" { |
| key = hierarchy + "." + key |
| } |
| goplsProperties[key] = obj |
| } |
| } |
| return goplsProperties, goProperties, nil |
| } |
| |
| func toObject(opt *Option) (*Object, error) { |
| doc := opt.Doc |
| if mappedTo, ok := associatedToExtensionProperties[opt.Name]; ok { |
| doc = fmt.Sprintf("%v\nIf unspecified, values of `%v` will be propagated.\n", doc, strings.Join(mappedTo, ", ")) |
| } |
| obj := &Object{ |
| MarkdownDescription: doc, |
| // TODO: are all gopls settings in the resource scope? |
| Scope: "resource", |
| // TODO: consider 'additionalProperties' if gopls api-json |
| // outputs acceptable properties. |
| DeprecationMessage: opt.DeprecationMessage, |
| } |
| if opt.Type != "enum" { |
| obj.Type = propertyType(opt.Type) |
| } else { // Map enum type to a sum type. |
| // Assume value type is bool | string. |
| seenTypes := map[string]bool{} |
| for _, v := range opt.EnumValues { |
| // EnumValue.Value: string in JSON syntax (quoted) |
| var x any |
| if err := json.Unmarshal([]byte(v.Value), &x); err != nil { |
| return nil, fmt.Errorf("failed to unmarshal %q: %v", v.Value, err) |
| } |
| |
| switch t := x.(type) { |
| case string: |
| obj.Enum = append(obj.Enum, t) |
| seenTypes["string"] = true |
| case bool: |
| obj.Enum = append(obj.Enum, t) |
| seenTypes["bool"] = true |
| default: |
| panic(fmt.Sprintf("type %T %+v as enum value type is not supported", t, t)) |
| } |
| obj.MarkdownEnumDescriptions = append(obj.MarkdownEnumDescriptions, v.Doc) |
| } |
| obj.Type = propertyType(slices.Sorted(maps.Keys(seenTypes))...) |
| } |
| // Handle objects whose keys are listed in EnumKeys. |
| // Gopls uses either enum or string as key types, for example, |
| // map[string]bool: analyses |
| // map[enum]bool: codelenses, annotations |
| // Both cases where 'enum' is used as a key type actually use |
| // only string type enum. For simplicity, map all to string-keyed objects. |
| if len(opt.EnumKeys.Keys) > 0 { |
| if obj.Properties == nil { |
| obj.Properties = map[string]*Object{} |
| } |
| for _, k := range opt.EnumKeys.Keys { |
| unquotedName, err := strconv.Unquote(k.Name) |
| if err != nil { |
| return nil, err |
| } |
| obj.Properties[unquotedName] = &Object{ |
| Type: propertyType(opt.EnumKeys.ValueType), |
| MarkdownDescription: k.Doc, |
| Default: formatDefault(k.Default, opt.EnumKeys.ValueType), |
| } |
| } |
| } |
| obj.Default = formatOptionDefault(opt) |
| |
| return obj, nil |
| } |
| |
| func formatOptionDefault(opt *Option) interface{} { |
| // Each key will have its own default value, instead of one large global |
| // one. (Alternatively, we can build the default from the keys.) |
| if len(opt.EnumKeys.Keys) > 0 { |
| return nil |
| } |
| |
| return formatDefault(opt.Default, opt.Type) |
| } |
| |
| // formatDefault converts a string-based default value to an actual value that |
| // can be marshaled to JSON. Right now, gopls generates default values as |
| // strings, but perhaps that will change. |
| func formatDefault(def, typ string) interface{} { |
| switch typ { |
| case "enum", "string", "time.Duration": |
| unquote, err := strconv.Unquote(def) |
| if err == nil { |
| def = unquote |
| } |
| case "[]string": |
| var x []string |
| if err := json.Unmarshal([]byte(def), &x); err == nil { |
| return x |
| } |
| } |
| switch def { |
| case "{}", "[]": |
| return nil |
| case "true": |
| return true |
| case "false": |
| return false |
| default: |
| return def |
| } |
| } |
| |
| var associatedToExtensionProperties = map[string][]string{ |
| "buildFlags": {"go.buildFlags", "go.buildTags"}, |
| } |
| |
| func propertyType(typs ...string) any /* string or []string */ { |
| if len(typs) == 0 { |
| panic("unexpected: len(typs) == 0") |
| } |
| if len(typs) == 1 { |
| return mapType(typs[0]) |
| } |
| |
| var ret []string |
| for _, t := range typs { |
| ret = append(ret, mapType(t)) |
| } |
| return ret |
| } |
| |
| func mapType(t string) string { |
| switch t { |
| case "string": |
| return "string" |
| case "bool": |
| return "boolean" |
| case "time.Duration": |
| return "string" |
| case "[]string": |
| return "array" |
| case "map[string]string", "map[string]bool", "map[enum]string", "map[enum]bool": |
| return "object" |
| case "any": |
| return "boolean" |
| } |
| log.Fatalf("unknown type %q", t) |
| return "" |
| } |
| |
| // Object represents a VS Code settings object. |
| type Object struct { |
| Type any `json:"type,omitempty"` // string | []string |
| MarkdownDescription string `json:"markdownDescription,omitempty"` |
| AdditionalProperties bool `json:"additionalProperties,omitempty"` |
| Enum []any `json:"enum,omitempty"` |
| MarkdownEnumDescriptions []string `json:"markdownEnumDescriptions,omitempty"` |
| Default interface{} `json:"default,omitempty"` |
| Scope string `json:"scope,omitempty"` |
| Properties map[string]*Object `json:"properties,omitempty"` |
| DeprecationMessage string `json:"deprecationMessage,omitempty"` |
| } |
| |
| type Status int |
| |
| const ( |
| Experimental = Status(iota) |
| Debug |
| Advanced |
| None |
| ) |
| |
| // API is a JSON-encodable representation of gopls' public interfaces. |
| // |
| // Types are copied from golang.org/x/tools/gopls/internal/doc/api.go. |
| type API struct { |
| Options map[string][]*Option |
| Lenses []*Lens |
| Analyzers []*Analyzer |
| Hints []*Hint |
| } |
| |
| type Option struct { |
| Name string |
| Type string // T = bool | string | int | enum | any | []T | map[T]T | time.Duration |
| Doc string |
| EnumKeys EnumKeys |
| EnumValues []EnumValue |
| Default string |
| Status string |
| Hierarchy string |
| DeprecationMessage string |
| } |
| |
| type EnumKeys struct { |
| ValueType string |
| Keys []EnumKey |
| } |
| |
| type EnumKey struct { |
| Name string // in JSON syntax (quoted) |
| Doc string |
| Default string |
| Status string // = "" | "advanced" | "experimental" | "deprecated" |
| } |
| |
| type EnumValue struct { |
| Value string // in JSON syntax (quoted) |
| Doc string // doc comment; always starts with `Value` |
| Status string // = "" | "advanced" | "experimental" | "deprecated" |
| } |
| |
| type Lens struct { |
| FileType string // e.g. "Go", "go.mod" |
| Lens string |
| Title string |
| Doc string |
| Default bool |
| Status string // = "" | "advanced" | "experimental" | "deprecated" |
| } |
| |
| type Analyzer struct { |
| Name string |
| Doc string // from analysis.Analyzer.Doc ("title: summary\ndescription"; not Markdown) |
| URL string |
| Default bool |
| } |
| |
| type Hint struct { |
| Name string |
| Doc string |
| Default bool |
| Status string // = "" | "advanced" | "experimental" | "deprecated" |
| } |