// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// The generate command updates the following files of documentation:
//
//	gopls/doc/settings.md   -- from linking gopls/internal/settings.DefaultOptions
//	gopls/doc/analyzers.md  -- from linking gopls/internal/settings.DefaultAnalyzers
//	gopls/doc/inlayHints.md -- from loading gopls/internal/settings.InlayHint
//	gopls/internal/doc/api.json -- all of the above in a single value, for 'gopls api-json'
//
// Run it with this command:
//
//	$ cd gopls/internal/doc/generate && go generate
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"go/ast"
	"go/doc/comment"
	"go/token"
	"go/types"
	"log"
	"maps"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode"

	goastutil "golang.org/x/tools/go/ast/astutil"
	"golang.org/x/tools/go/packages"
	"golang.org/x/tools/gopls/internal/cache"
	"golang.org/x/tools/gopls/internal/doc"
	"golang.org/x/tools/gopls/internal/golang"
	"golang.org/x/tools/gopls/internal/mod"
	"golang.org/x/tools/gopls/internal/settings"
	"golang.org/x/tools/gopls/internal/util/safetoken"
	"golang.org/x/tools/internal/astutil"
)

func main() {
	if _, err := doMain(true); err != nil {
		fmt.Fprintf(os.Stderr, "Generation failed: %v\n", err)
		os.Exit(1)
	}
}

// doMain regenerates the output files. On success:
// - if write, it updates them;
// - if !write, it reports whether they would change.
func doMain(write bool) (bool, error) {
	api, err := loadAPI()
	if err != nil {
		return false, err
	}

	goplsDir, err := pkgDir("golang.org/x/tools/gopls")
	if err != nil {
		return false, err
	}

	// TODO(adonovan): consider using HTML, not Markdown, for the
	// generated reference documents. It's not more difficult, the
	// layout is easier to read, and we can use go/doc-comment
	// rendering logic.

	for _, f := range []struct {
		name    string // relative to gopls
		rewrite rewriter
	}{
		{"internal/doc/api.json", rewriteAPI},
		{"doc/settings.md", rewriteSettings},
		{"doc/codelenses.md", rewriteCodeLenses},
		{"doc/analyzers.md", rewriteAnalyzers},
		{"doc/inlayHints.md", rewriteInlayHints},
	} {
		file := filepath.Join(goplsDir, f.name)
		old, err := os.ReadFile(file)
		if err != nil {
			return false, err
		}

		new, err := f.rewrite(old, api)
		if err != nil {
			return false, fmt.Errorf("rewriting %q: %v", file, err)
		}

		if write {
			if err := os.WriteFile(file, new, 0); err != nil {
				return false, err
			}
		} else if !bytes.Equal(old, new) {
			return false, nil // files would change
		}
	}
	return true, nil
}

// A rewriter is a function that transforms the content of a file.
type rewriter = func([]byte, *doc.API) ([]byte, error)

// pkgDir returns the directory corresponding to the import path pkgPath.
func pkgDir(pkgPath string) (string, error) {
	cmd := exec.Command("go", "list", "-f", "{{.Dir}}", pkgPath)
	out, err := cmd.Output()
	if err != nil {
		if ee, _ := err.(*exec.ExitError); ee != nil && len(ee.Stderr) > 0 {
			return "", fmt.Errorf("%v: %w\n%s", cmd, err, ee.Stderr)
		}
		return "", fmt.Errorf("%v: %w", cmd, err)
	}
	return strings.TrimSpace(string(out)), nil
}

// loadAPI computes the JSON-encodable value that describes gopls'
// interfaces, by a combination of static and dynamic analysis.
func loadAPI() (*doc.API, error) {
	pkgs, err := packages.Load(
		&packages.Config{
			Mode: packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedDeps,
		},
		"golang.org/x/tools/gopls/internal/settings",
	)
	if err != nil {
		return nil, err
	}
	settingsPkg := pkgs[0]

	defaults := settings.DefaultOptions()
	api := &doc.API{
		Options:   map[string][]*doc.Option{},
		Analyzers: loadAnalyzers(settings.AllAnalyzers, defaults),
	}

	api.Lenses, err = loadLenses(settingsPkg, defaults.Codelenses)
	if err != nil {
		return nil, err
	}
	api.Hints, err = loadHints(settingsPkg)
	if err != nil {
		return nil, err
	}

	for _, category := range []reflect.Value{
		reflect.ValueOf(defaults.UserOptions),
	} {
		// Find the type information and ast.File corresponding to the category.
		optsType := settingsPkg.Types.Scope().Lookup(category.Type().Name())
		if optsType == nil {
			return nil, fmt.Errorf("could not find %v in scope %v", category.Type().Name(), settingsPkg.Types.Scope())
		}
		opts, err := loadOptions(category, optsType, settingsPkg, "")
		if err != nil {
			return nil, err
		}

		// Edge case for "analyses": populate its enum keys from
		// the analyzer list, since its map keys are strings, not enums.
		// Also, set its EnumKeys.ValueType for historical reasons.
		for _, opt := range opts {
			if opt.Name == "analyses" {
				opt.EnumKeys.ValueType = "bool"
				for _, a := range api.Analyzers {
					opt.EnumKeys.Keys = append(opt.EnumKeys.Keys, doc.EnumKey{
						Name:    fmt.Sprintf("%q", a.Name),
						Doc:     a.Doc,
						Default: strconv.FormatBool(a.Default),
					})
				}
			}
		}

		catName := strings.TrimSuffix(category.Type().Name(), "Options")
		api.Options[catName] = opts
	}
	return api, nil
}

// loadOptions computes a single category of settings by a combination
// of static analysis and reflection over gopls internal types.
func loadOptions(category reflect.Value, optsType types.Object, pkg *packages.Package, hierarchy string) ([]*doc.Option, error) {
	file, err := fileForPos(pkg, optsType.Pos())
	if err != nil {
		return nil, err
	}

	enums, err := loadEnums(pkg) // TODO(adonovan): do this only once at toplevel.
	if err != nil {
		return nil, err
	}

	var opts []*doc.Option
	optsStruct := optsType.Type().Underlying().(*types.Struct)
	for typesField := range optsStruct.Fields() {
		// The types field gives us the type.

		// If the field name ends with "Options", assume it is a struct with
		// additional options and process it recursively.
		if h, ok := strings.CutSuffix(typesField.Name(), "Options"); ok {
			// Keep track of the parent structs.
			if hierarchy != "" {
				h = hierarchy + "." + h
			}
			options, err := loadOptions(category, typesField, pkg, strings.ToLower(h))
			if err != nil {
				return nil, err
			}
			opts = append(opts, options...)
			continue
		}
		path, _ := goastutil.PathEnclosingInterval(file, typesField.Pos(), typesField.Pos())
		if len(path) < 2 {
			return nil, fmt.Errorf("could not find AST node for field %v", typesField)
		}

		// The AST field gives us the doc.
		astField, ok := path[1].(*ast.Field)
		if !ok {
			return nil, fmt.Errorf("unexpected AST path %v", path)
		}
		description, deprecation := astField.Doc.Text(), astutil.Deprecation(astField.Doc)

		// The reflect field gives us the default value.
		reflectField := category.FieldByName(typesField.Name())
		if !reflectField.IsValid() {
			return nil, fmt.Errorf("could not find reflect field for %v", typesField.Name())
		}

		def, err := formatDefault(reflectField)
		if err != nil {
			return nil, err
		}

		// Derive the doc-and-api.json type from the Go field type.
		//
		// In principle, we should use JSON nomenclature here
		// (number, array, object, etc; see #68057), but in
		// practice we use the Go type string ([]T, map[K]V,
		// etc) with only one tweak: enumeration types are
		// replaced by "enum", including when they appear as
		// map keys.
		//
		// Notable edge cases:
		// - any (e.g. in linksInHover) is really a sum of false | true | "internal".
		// - time.Duration is really a string with a particular syntax.
		typ := typesField.Type().String()
		if _, ok := enums[typesField.Type()]; ok {
			typ = "enum"
		}
		name := lowerFirst(typesField.Name())

		// enum-keyed maps
		var enumKeys doc.EnumKeys
		if m, ok := typesField.Type().Underlying().(*types.Map); ok {
			if values, ok := enums[m.Key()]; ok {
				// Update type name: "map[CodeLensSource]T" -> "map[enum]T"
				// hack: assumes key substring is unique!
				typ = strings.Replace(typ, m.Key().String(), "enum", 1)

				enumKeys.ValueType = m.Elem().String() // e.g. bool

				// For map[enum]T fields, gather the set of valid
				// EnumKeys (from type information). If T=bool, also
				// record the default value (from reflection).
				keys, err := collectEnumKeys(m, reflectField, values)
				if err != nil {
					return nil, err
				}
				enumKeys.Keys = keys
			}
		}

		// Get the status of the field by checking its struct tags.
		reflectStructField, ok := category.Type().FieldByName(typesField.Name())
		if !ok {
			return nil, fmt.Errorf("no struct field for %s", typesField.Name())
		}
		status := reflectStructField.Tag.Get("status")

		opts = append(opts, &doc.Option{
			Name:               name,
			Type:               typ,
			Doc:                lowerFirst(description),
			Default:            def,
			EnumKeys:           enumKeys,
			EnumValues:         enums[typesField.Type()],
			Status:             status,
			Hierarchy:          hierarchy,
			DeprecationMessage: lowerFirst(strings.TrimPrefix(deprecation, "Deprecated: ")),
		})
	}
	return opts, nil
}

// loadEnums returns a description of gopls' settings enum types based on static analysis.
func loadEnums(pkg *packages.Package) (map[types.Type][]doc.EnumValue, error) {
	enums := make(map[types.Type][]doc.EnumValue)
	for _, name := range pkg.Types.Scope().Names() {
		obj := pkg.Types.Scope().Lookup(name)
		cnst, ok := obj.(*types.Const)
		if !ok {
			continue
		}
		f, err := fileForPos(pkg, cnst.Pos())
		if err != nil {
			return nil, fmt.Errorf("finding file for %q: %v", cnst.Name(), err)
		}
		path, _ := goastutil.PathEnclosingInterval(f, cnst.Pos(), cnst.Pos())
		spec := path[1].(*ast.ValueSpec)
		value := cnst.Val().ExactString()
		docstring := valueDoc(cnst.Name(), value, spec.Doc.Text())
		var status string
		for _, d := range astutil.Directives(spec.Doc) {
			if d.Tool == "gopls" && d.Name == "status" {
				status = d.Args
				break
			}
		}
		v := doc.EnumValue{
			Value:  value,
			Doc:    docstring,
			Status: status,
		}
		enums[obj.Type()] = append(enums[obj.Type()], v)
	}

	// linksInHover is a one-off edge case (true | false | "gopls")
	// that doesn't warrant a general solution (e.g. struct tag).
	enums[pkg.Types.Scope().Lookup("LinksInHoverEnum").Type()] = []doc.EnumValue{
		{Value: "false", Doc: "false: do not show links"},
		{Value: "true", Doc: "true: show links to the `linkTarget` domain"},
		{Value: `"gopls"`, Doc: "`\"gopls\"`: show links to gopls' internal documentation viewer"},
	}

	return enums, nil
}

func collectEnumKeys(m *types.Map, reflectField reflect.Value, enumValues []doc.EnumValue) ([]doc.EnumKey, error) {
	// We can get default values for enum -> bool maps.
	var isEnumBoolMap bool
	if basic, ok := m.Elem().Underlying().(*types.Basic); ok && basic.Kind() == types.Bool {
		isEnumBoolMap = true
	}
	var keys []doc.EnumKey
	for _, v := range enumValues {
		var def string
		if isEnumBoolMap {
			var err error
			def, err = formatDefaultFromEnumBoolMap(reflectField, v.Value)
			if err != nil {
				return nil, err
			}
		}
		keys = append(keys, doc.EnumKey{
			Name:    v.Value,
			Doc:     v.Doc,
			Status:  v.Status,
			Default: def,
		})
	}
	return keys, nil
}

func formatDefaultFromEnumBoolMap(reflectMap reflect.Value, enumKey string) (string, error) {
	if reflectMap.Kind() != reflect.Map {
		return "", nil
	}
	name := enumKey
	if unquoted, err := strconv.Unquote(name); err == nil {
		name = unquoted
	}
	for _, e := range reflectMap.MapKeys() {
		if e.String() == name {
			value := reflectMap.MapIndex(e)
			if value.Type().Kind() == reflect.Bool {
				return formatDefault(value)
			}
		}
	}
	// Assume that if the value isn't mentioned in the map, it defaults to
	// the default value, false.
	return formatDefault(reflect.ValueOf(false))
}

// formatDefault formats the default value into a JSON-like string.
// VS Code exposes settings as JSON, so showing them as JSON is reasonable.
// TODO(rstambler): Reconsider this approach, as the VS Code Go generator now
// marshals to JSON.
func formatDefault(reflectField reflect.Value) (string, error) {
	def := reflectField.Interface()

	// Durations marshal as nanoseconds, but we want the stringy versions,
	// e.g. "100ms".
	if t, ok := def.(time.Duration); ok {
		def = t.String()
	}
	defBytes, err := json.Marshal(def)
	if err != nil {
		return "", err
	}

	// Nil values format as "null" so print them as hardcoded empty values.
	switch reflectField.Type().Kind() {
	case reflect.Map:
		if reflectField.IsNil() {
			defBytes = []byte("{}")
		}
	case reflect.Slice:
		if reflectField.IsNil() {
			defBytes = []byte("[]")
		}
	}
	return string(defBytes), err
}

// valueDoc transforms a docstring documenting a constant identifier to a
// docstring documenting its value.
//
// If doc is of the form "Foo is a bar", it returns '`"fooValue"` is a bar'. If
// doc is non-standard ("this value is a bar"), it returns '`"fooValue"`: this
// value is a bar'.
func valueDoc(name, value, doc string) string {
	if doc == "" {
		return ""
	}
	if strings.HasPrefix(doc, name) {
		// docstring in standard form. Replace the subject with value.
		return fmt.Sprintf("`%s`%s", value, doc[len(name):])
	}
	return fmt.Sprintf("`%s`: %s", value, doc)
}

// loadLenses combines the syntactic comments from the settings
// package with the default values from settings.DefaultOptions(), and
// returns a list of Code Lens descriptors.
func loadLenses(settingsPkg *packages.Package, defaults map[settings.CodeLensSource]bool) ([]*doc.Lens, error) {
	// Find the CodeLensSource enums among the files of the protocol package.
	// Map each enum value to its doc comment.
	enumDoc := make(map[string]string)
	enumStatus := make(map[string]string)
	for _, f := range settingsPkg.Syntax {
		for _, decl := range f.Decls {
			if decl, ok := decl.(*ast.GenDecl); ok && decl.Tok == token.CONST {
				for _, spec := range decl.Specs {
					spec := spec.(*ast.ValueSpec)
					posn := safetoken.StartPosition(settingsPkg.Fset, spec.Pos())
					if id, ok := spec.Type.(*ast.Ident); ok && id.Name == "CodeLensSource" {
						if len(spec.Names) != 1 || len(spec.Values) != 1 {
							return nil, fmt.Errorf("%s: declare one CodeLensSource per line", posn)
						}
						lit, ok := spec.Values[0].(*ast.BasicLit)
						if !ok || lit.Kind != token.STRING {
							return nil, fmt.Errorf("%s: CodeLensSource value is not a string literal", posn)
						}
						value, _ := strconv.Unquote(lit.Value) // ignore error: AST is well-formed
						if spec.Doc == nil {
							return nil, fmt.Errorf("%s: %s lacks doc comment", posn, spec.Names[0].Name)
						}
						enumDoc[value] = spec.Doc.Text()
						for _, d := range astutil.Directives(spec.Doc) {
							if d.Tool == "gopls" && d.Name == "status" {
								enumStatus[value] = d.Args
								break
							}
						}
					}
				}
			}
		}
	}
	if len(enumDoc) == 0 {
		return nil, fmt.Errorf("failed to extract any CodeLensSource declarations")
	}

	// Build list of Lens descriptors.
	var lenses []*doc.Lens
	addAll := func(sources map[settings.CodeLensSource]cache.CodeLensSourceFunc, fileType string) error {
		for _, source := range slices.Sorted(maps.Keys(sources)) {
			docText, ok := enumDoc[string(source)]
			if !ok {
				return fmt.Errorf("missing CodeLensSource declaration for %s", source)
			}
			title, docText, _ := strings.Cut(docText, "\n") // first line is title
			lenses = append(lenses, &doc.Lens{
				FileType: fileType,
				Lens:     string(source),
				Title:    title,
				Doc:      docText,
				Default:  defaults[source],
				Status:   enumStatus[string(source)],
			})
		}
		return nil
	}
	err := errors.Join(
		addAll(golang.CodeLensSources(), "Go"),
		addAll(mod.CodeLensSources(), "go.mod"))
	return lenses, err
}

func loadAnalyzers(analyzers []*settings.Analyzer, defaults *settings.Options) []*doc.Analyzer {
	slices.SortFunc(analyzers, func(x, y *settings.Analyzer) int {
		return strings.Compare(x.Analyzer().Name, y.Analyzer().Name)
	})
	var json []*doc.Analyzer
	for _, a := range analyzers {
		json = append(json, &doc.Analyzer{
			Name:    a.Analyzer().Name,
			Doc:     a.Analyzer().Doc,
			URL:     a.Analyzer().URL,
			Default: a.Enabled(defaults),
		})
	}
	return json
}

// loadHints derives and returns the inlay hints metadata from the settings.InlayHint type.
func loadHints(settingsPkg *packages.Package) ([]*doc.Hint, error) {
	enums, err := loadEnums(settingsPkg) // TODO(adonovan): call loadEnums exactly once
	if err != nil {
		return nil, err
	}
	inlayHint := settingsPkg.Types.Scope().Lookup("InlayHint").Type()
	var hints []*doc.Hint
	for _, enumVal := range enums[inlayHint] {
		name, _ := strconv.Unquote(enumVal.Value)
		hints = append(hints, &doc.Hint{
			Name:   name,
			Doc:    enumVal.Doc,
			Status: enumVal.Status,
		})
	}
	return hints, nil
}

func lowerFirst(x string) string {
	if x == "" {
		return x
	}
	return strings.ToLower(x[:1]) + x[1:]
}

func fileForPos(pkg *packages.Package, pos token.Pos) (*ast.File, error) {
	fset := pkg.Fset
	for _, f := range pkg.Syntax {
		if safetoken.StartPosition(fset, f.FileStart).Filename == safetoken.StartPosition(fset, pos).Filename {
			return f, nil
		}
	}
	return nil, fmt.Errorf("no file for pos %v", pos)
}

func rewriteAPI(_ []byte, api *doc.API) ([]byte, error) {
	return json.MarshalIndent(api, "", "\t")
}

type optionsGroup struct {
	title   string // dotted path (e.g. "ui.documentation")
	final   string // final segment of title (e.g. "documentation")
	level   int
	options []*doc.Option
}

func rewriteSettings(prevContent []byte, api *doc.API) ([]byte, error) {
	content := prevContent
	for category, opts := range api.Options {
		groups := collectGroups(opts)

		var buf bytes.Buffer

		// First, print a table of contents (ToC).
		fmt.Fprintln(&buf)
		for _, h := range groups {
			title := h.final
			if title != "" {
				fmt.Fprintf(&buf, "%s* [%s](#%s)\n",
					strings.Repeat("  ", h.level),
					capitalize(title),
					strings.ToLower(title))
			}
		}

		// Section titles are h2, options are h3.
		// This is independent of the option hierarchy.
		// (Nested options should not be smaller!)
		fmt.Fprintln(&buf)
		for _, h := range groups {
			title := h.final
			if title != "" {
				// Emit HTML anchor as GitHub markdown doesn't support
				// "# Heading {#anchor}" syntax.
				fmt.Fprintf(&buf, "<a id='%s'></a>\n", strings.ToLower(title))

				fmt.Fprintf(&buf, "## %s\n\n", capitalize(title))
			}
			for _, opt := range h.options {
				// Emit HTML anchor as GitHub markdown doesn't support
				// "# Heading {#anchor}" syntax.
				//
				// (Each option name is the camelCased name of a field of
				// settings.UserOptions or one of its FooOptions subfields.)
				fmt.Fprintf(&buf, "<a id='%s'></a>\n", opt.Name)

				// heading
				//
				// We do not display the undocumented dotted-path alias
				// (h.title + "." + opt.Name) used by VS Code only.
				fmt.Fprintf(&buf, "### `%s %s`\n\n", opt.Name, opt.Type)

				// status
				writeStatus(&buf, opt.Status)

				// doc comment
				buf.WriteString(opt.Doc)

				// enums
				write := func(name, doc string) {
					if doc != "" {
						unbroken := parBreakRE.ReplaceAllString(doc, "\\\n")
						fmt.Fprintf(&buf, "* %s\n", strings.TrimSpace(unbroken))
					} else {
						fmt.Fprintf(&buf, "* `%s`\n", name)
					}
				}
				if len(opt.EnumValues) > 0 && opt.Type == "enum" {
					// enum as top-level type constructor
					buf.WriteString("\nMust be one of:\n\n")
					for _, val := range opt.EnumValues {
						write(val.Value, val.Doc)
					}
				} else if len(opt.EnumKeys.Keys) > 0 && shouldShowEnumKeysInSettings(opt.Name) {
					// enum as map key (currently just "annotations")
					buf.WriteString("\nEach enum must be one of:\n\n")
					for _, val := range opt.EnumKeys.Keys {
						write(val.Name, val.Doc)
					}
				}

				// default value
				fmt.Fprintf(&buf, "\nDefault: `%v`.\n\n", opt.Default)
			}
		}
		newContent, err := replaceSection(content, category, buf.Bytes())
		if err != nil {
			return nil, err
		}
		content = newContent
	}
	return content, nil
}

// writeStatus emits a Markdown paragraph to buf about the status of a feature,
// if nonempty.
func writeStatus(buf *bytes.Buffer, status string) {
	switch status {
	case "":
	case "advanced":
		fmt.Fprint(buf, "**This is an advanced setting and should not be configured by most `gopls` users.**\n\n")
	case "debug":
		fmt.Fprint(buf, "**This setting is for debugging purposes only.**\n\n")
	case "experimental":
		fmt.Fprint(buf, "**This setting is experimental and may be deleted.**\n\n")
	default:
		fmt.Fprintf(buf, "**Status: %s.**\n\n", status)
	}
}

var parBreakRE = regexp.MustCompile("\n{2,}")

func shouldShowEnumKeysInSettings(name string) bool {
	// These fields have too many possible options,
	// or too voluminous documentation, to render as enums.
	// Instead they each get their own page in the manual.
	return !(name == "analyses" || name == "codelenses" || name == "hints")
}

func collectGroups(opts []*doc.Option) []optionsGroup {
	optsByHierarchy := map[string][]*doc.Option{}
	for _, opt := range opts {
		optsByHierarchy[opt.Hierarchy] = append(optsByHierarchy[opt.Hierarchy], opt)
	}

	// As a hack, assume that uncategorized items are less important to
	// users and force the empty string to the end of the list.
	var containsEmpty bool
	var sorted []string
	for h := range optsByHierarchy {
		if h == "" {
			containsEmpty = true
			continue
		}
		sorted = append(sorted, h)
	}
	sort.Strings(sorted)
	if containsEmpty {
		sorted = append(sorted, "")
	}
	var groups []optionsGroup
	baseLevel := 0
	for _, h := range sorted {
		split := strings.SplitAfter(h, ".")
		last := split[len(split)-1]
		// Hack to capitalize all of UI.
		if last == "ui" {
			last = "UI"
		}
		// A hierarchy may look like "ui.formatting". If "ui" has no
		// options of its own, it may not be added to the map, but it
		// still needs a heading.
		components := strings.Split(h, ".")
		for i := 1; i < len(components); i++ {
			parent := strings.Join(components[0:i], ".")
			if _, ok := optsByHierarchy[parent]; !ok {
				groups = append(groups, optionsGroup{
					title: parent,
					final: last,
					level: baseLevel + i,
				})
			}
		}
		groups = append(groups, optionsGroup{
			title:   h,
			final:   last,
			level:   baseLevel + strings.Count(h, "."),
			options: optsByHierarchy[h],
		})
	}
	return groups
}

func capitalize(s string) string {
	return string(unicode.ToUpper(rune(s[0]))) + s[1:]
}

func rewriteCodeLenses(prevContent []byte, api *doc.API) ([]byte, error) {
	var buf bytes.Buffer
	for _, lens := range api.Lenses {
		fmt.Fprintf(&buf, "## `%s`: %s\n\n", lens.Lens, lens.Title)
		writeStatus(&buf, lens.Status)
		fmt.Fprintf(&buf, "%s\n\n", lens.Doc)
		fmt.Fprintf(&buf, "Default: %v\n\n", onOff(lens.Default))
		fmt.Fprintf(&buf, "File type: %s\n\n", lens.FileType)
	}
	return replaceSection(prevContent, "Lenses", buf.Bytes())
}

func rewriteAnalyzers(prevContent []byte, api *doc.API) ([]byte, error) {
	seen := make(map[string]string) // name->doc, for reporting dups

	var buf bytes.Buffer
	for _, analyzer := range api.Analyzers {
		if prevDoc, ok := seen[analyzer.Name]; ok {
			log.Fatalf("duplicate analyzer name %q\n===1===\n%s\n===2===\n%s", analyzer.Name, analyzer.Doc, prevDoc)
		}
		seen[analyzer.Name] = analyzer.Doc

		fmt.Fprintf(&buf, "<a id='%s'></a>\n", analyzer.Name)
		title, doc, _ := strings.Cut(analyzer.Doc, "\n")
		title = strings.TrimPrefix(title, analyzer.Name+": ")
		fmt.Fprintf(&buf, "## `%s`: %s\n\n", analyzer.Name, title)

		// Convert Analyzer.Doc from go/doc/comment form to Markdown.
		// Headings in doc comments are converted to ### (HeadingLevel=3).
		//
		// Some Analyzers (e.g. go/analysis/passes/inline) use ## to indicate subheadings
		// Although this is valid Markdown, it is not valid go/doc/comment,
		// nor is it rendered as a subheading by pkg.go.dev or gopls's doc viewer.
		// Perhaps it will be supported in future; see
		// https://github.com/golang/go/issues/51082#issuecomment-1033116430 et seq.
		//
		// In the meantime, the go/doc/comment processing will escape them so
		// that the ## appears literally in analyzers.md just as it does in
		// the two viewers mentioned above. The meaning is clear enough.
		doctree := new(comment.Parser).Parse(doc)
		buf.Write((&comment.Printer{HeadingLevel: 3}).Markdown(doctree))
		buf.WriteString("\n\n")

		fmt.Fprintf(&buf, "Default: %s.", onOff(analyzer.Default))
		if !analyzer.Default {
			fmt.Fprintf(&buf, " Enable by setting `\"analyses\": {\"%s\": true}`.", analyzer.Name)
		}
		fmt.Fprintf(&buf, "\n\n")
		if analyzer.URL != "" {
			// TODO(adonovan): currently the URL provides the same information
			// as 'doc' above, though that may change due to
			// https://github.com/golang/go/issues/61315#issuecomment-1841350181.
			// In that case, update this to something like "Complete documentation".
			fmt.Fprintf(&buf, "Package documentation: [%s](%s)\n\n",
				analyzer.Name, analyzer.URL)
		}
	}
	return replaceSection(prevContent, "Analyzers", buf.Bytes())
}

func rewriteInlayHints(prevContent []byte, api *doc.API) ([]byte, error) {
	var buf bytes.Buffer
	for _, hint := range api.Hints {
		fmt.Fprintf(&buf, "## **%v**\n\n", hint.Name)
		fmt.Fprintf(&buf, "%s\n\n", hint.Doc)
		switch hint.Default {
		case true:
			fmt.Fprintf(&buf, "**Enabled by default.**\n\n")
		case false:
			fmt.Fprintf(&buf, "**Disabled by default. Enable it by setting `\"hints\": {\"%s\": true}`.**\n\n", hint.Name)
		}
	}
	return replaceSection(prevContent, "Hints", buf.Bytes())
}

// replaceSection replaces the portion of a file delimited by comments of the form:
//
//	<!-- BEGIN sectionName -->
//	<!-- END section Name -->
func replaceSection(content []byte, sectionName string, replacement []byte) ([]byte, error) {
	re := regexp.MustCompile(fmt.Sprintf(`(?s)<!-- BEGIN %v.* -->\n(.*?)<!-- END %v.* -->`, sectionName, sectionName))
	idx := re.FindSubmatchIndex(content)
	if idx == nil {
		return nil, fmt.Errorf("could not find section %q", sectionName)
	}
	result := slices.Clone(content[:idx[2]])
	result = append(result, replacement...)
	result = append(result, content[idx[3]:]...)
	return result, nil
}

type onOff bool

func (o onOff) String() string {
	if o {
		return "on"
	} else {
		return "off"
	}
}
