go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/starlark/docgen/generator.go (about)

     1  // Copyright 2019 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package docgen generates documentation from Starlark code.
    16  package docgen
    17  
    18  import (
    19  	"bytes"
    20  	"fmt"
    21  	"regexp"
    22  	"strings"
    23  	"text/template"
    24  
    25  	"go.chromium.org/luci/common/data/stringset"
    26  	"go.chromium.org/luci/starlark/docgen/ast"
    27  	"go.chromium.org/luci/starlark/docgen/docstring"
    28  	"go.chromium.org/luci/starlark/docgen/symbols"
    29  )
    30  
    31  // Generator renders text templates that have access to parsed structured
    32  // representation of Starlark modules.
    33  //
    34  // The templates use them to inject documentation extracted from Starlark into
    35  // appropriate places.
    36  //
    37  // It is a cache of the current loaded modules, which enables following
    38  // Render() calls to be much faster.
    39  type Generator struct {
    40  	// Normalize normalizes a load() reference relative to the context of the
    41  	// current starlark file.
    42  	Normalize func(parent, ref string) (string, error)
    43  	// Starlark produces Starlark module's source code.
    44  	//
    45  	// It is then parsed by the generator to extract documentation from it.
    46  	Starlark func(module string) (src string, err error)
    47  
    48  	loader *symbols.Loader    // knows how to load symbols from starlark modules
    49  	links  map[string]*symbol // full name -> symbol we can link to
    50  }
    51  
    52  // Render renders the given text template in an environment with access to
    53  // parsed structured Starlark comments.
    54  //
    55  // Loaded modules are kept as a cache in Generator, making the rendering of
    56  // multiple starlark files faster.
    57  func (g *Generator) Render(templ string) ([]byte, error) {
    58  	if g.loader == nil {
    59  		g.loader = &symbols.Loader{Normalize: g.Normalize, Source: g.Starlark}
    60  		g.links = map[string]*symbol{}
    61  	}
    62  
    63  	t, err := template.New("main").Funcs(g.funcMap()).Parse(templ)
    64  	if err != nil {
    65  		return nil, err
    66  	}
    67  
    68  	buf := bytes.Buffer{}
    69  	if err := t.Execute(&buf, nil); err != nil {
    70  		return nil, err
    71  	}
    72  	return buf.Bytes(), nil
    73  }
    74  
    75  // funcMap are functions available to templates.
    76  func (g *Generator) funcMap() template.FuncMap {
    77  	return template.FuncMap{
    78  		"EscapeMD":       escapeMD,
    79  		"Symbol":         g.symbol,
    80  		"LinkifySymbols": g.linkifySymbols,
    81  	}
    82  }
    83  
    84  // escapeMD makes sure 's' gets rendered as is in markdown.
    85  func escapeMD(s string) string {
    86  	return strings.Replace(s, "*", "\\*", -1)
    87  }
    88  
    89  func (g *Generator) load(module string) (*symbols.Struct, error) {
    90  	mod, err := g.loader.Load(module)
    91  	if err != nil {
    92  		return nil, err
    93  	}
    94  
    95  	// Transform lucicfg.rule(...) definitions to pick up docstrings and arguments
    96  	// of the rule implementation. We replace `var = lucicfg.rule(impl = f)` with
    97  	// `var = f`.
    98  	return mod.Transform(func(s symbols.Symbol) (symbols.Symbol, error) {
    99  		inv, ok := s.(*symbols.Invocation)
   100  		if !ok {
   101  			return s, nil
   102  		}
   103  		// Rule constructor symbols are marked with RuleCtor tag.
   104  		targetTags := inv.Func().Doc().RemarkBlock("DocTags").Body
   105  		if !strings.Contains(targetTags, "RuleCtor") {
   106  			return s, nil
   107  		}
   108  		// Find a symbol assigned to 'impl' kwarg and return it, so that it is
   109  		// used instead of lucicfg.rule(...) invocation. Give it the name of 's'.
   110  		for _, arg := range inv.Args() {
   111  			if arg.Name() == "impl" {
   112  				return symbols.NewAlias(s.Name(), arg), nil
   113  			}
   114  		}
   115  		return nil, fmt.Errorf("cannot resolve rule constructor call in %s, no `impl` kwarg", s)
   116  	})
   117  }
   118  
   119  // symbol returns a symbol from the given module.
   120  //
   121  // lookup is a field path, e.g. "a.b.c". "a" will be searched for in the
   122  // top-level dict of the module. If empty, the module itself will be returned.
   123  //
   124  // If the requested symbol can't be found, returns a broken symbol.
   125  func (g *Generator) symbol(module, lookup string) (*symbol, error) {
   126  	mod, err := g.load(module)
   127  	if err != nil {
   128  		return nil, err
   129  	}
   130  
   131  	var lookupPath []string
   132  	if lookup != "" {
   133  		lookupPath = strings.Split(lookup, ".")
   134  	}
   135  
   136  	sym := &symbol{
   137  		Symbol:   symbols.Lookup(mod, lookupPath...),
   138  		Module:   module,
   139  		FullName: lookup,
   140  	}
   141  
   142  	// Let automatic linkifier know about the loaded symbols so it can start
   143  	// generating links to them if it encounters them in the text.
   144  	syms, _ := sym.Symbols()
   145  	for _, s := range syms {
   146  		g.links[s.FullName] = s
   147  	}
   148  
   149  	return sym, nil
   150  }
   151  
   152  // This matches a.b.c(...). '(...)' part is important, otherwise there is a ton
   153  // of undesired matches in various code snippets.
   154  var symRefRe = regexp.MustCompile(`\w+(\.\w+)*(\(\.\.\.\))`)
   155  
   156  // linkifySymbols replaces recognized symbol names with markdown links to
   157  // symbols.
   158  func (g *Generator) linkifySymbols(text string) string {
   159  	return symRefRe.ReplaceAllStringFunc(text, func(match string) string {
   160  		if sym := g.links[strings.TrimSuffix(match, "(...)")]; sym != nil {
   161  			return fmt.Sprintf("[%s](#%s)", match, sym.Anchor())
   162  		}
   163  		return match
   164  	})
   165  }
   166  
   167  ////////////////////////////////////////////////////////////////////////////////
   168  
   169  // symbol is what we expose to the templates.
   170  //
   171  // It is mostly symbols.Symbol, except we add few useful utility fields and
   172  // methods.
   173  type symbol struct {
   174  	symbols.Symbol
   175  
   176  	Module   string // module name used to load this symbol
   177  	FullName string // field path from module's top dict to this symbol
   178  
   179  	doc  *docstring.Parsed // lazily redacted doc, see Doc()
   180  	tags stringset.Set     // lazily extracted from "DocTags" remarks section
   181  }
   182  
   183  // Flavor returns one of "func", "var", "struct", "unknown".
   184  func (s *symbol) Flavor() string {
   185  	switch s.Symbol.(type) {
   186  	case *symbols.Term:
   187  		switch s.Symbol.Def().(type) {
   188  		case *ast.Function:
   189  			return "func"
   190  		case *ast.Var:
   191  			return "var"
   192  		default:
   193  			return "unknown"
   194  		}
   195  	case *symbols.Invocation:
   196  		return "inv"
   197  	case *symbols.Struct:
   198  		return "struct"
   199  	default:
   200  		return "unknown"
   201  	}
   202  }
   203  
   204  // Doc is a parsed docstring for this symbol.
   205  //
   206  // An argument called `ctx` is kicked out since it is part of the internal
   207  // lucicfg API.
   208  func (s *symbol) Doc() *docstring.Parsed {
   209  	if s.doc == nil {
   210  		s.doc = s.Symbol.Doc()
   211  		for i, block := range s.doc.Fields {
   212  			if block.Title == "Args" {
   213  				filtered := block.Fields[:0]
   214  				for _, field := range block.Fields {
   215  					if field.Name != "ctx" {
   216  						filtered = append(filtered, field)
   217  					}
   218  				}
   219  				block.Fields = filtered
   220  				s.doc.Fields[i] = block
   221  				break
   222  			}
   223  		}
   224  	}
   225  	return s.doc
   226  }
   227  
   228  // HasDocTag returns true if the docstring has a section "DocTags" and the
   229  // given tag is listed there.
   230  //
   231  // Used to mark some symbols as advanced, or experimental.
   232  func (s *symbol) HasDocTag(tag string) bool {
   233  	if s.tags == nil {
   234  		s.tags = stringset.Set{}
   235  		for _, word := range strings.Fields(s.Doc().RemarkBlock("DocTags").Body) {
   236  			s.tags.Add(strings.ToLower(strings.Trim(word, ".,")))
   237  		}
   238  	}
   239  	return s.tags.Has(strings.ToLower(tag))
   240  }
   241  
   242  // Symbols returns nested symbols.
   243  //
   244  // If `flavors` is not empty, it specifies what kinds of symbols to keep.
   245  // Possible variants: "func", "var", "inv", "struct".
   246  func (s *symbol) Symbols(flavors ...string) (out []*symbol, err error) {
   247  	strct, _ := s.Symbol.(*symbols.Struct)
   248  	if strct == nil {
   249  		return nil, fmt.Errorf("%q is not a struct", s.FullName)
   250  	}
   251  
   252  	keepFlavors := stringset.NewFromSlice(flavors...)
   253  
   254  	for _, sym := range strct.Symbols() {
   255  		fullName := ""
   256  		if s.FullName != "" {
   257  			fullName = s.FullName + "." + sym.Name()
   258  		} else {
   259  			fullName = sym.Name()
   260  		}
   261  
   262  		sym := &symbol{
   263  			Symbol:   sym,
   264  			Module:   s.Module,
   265  			FullName: fullName,
   266  		}
   267  
   268  		if keepFlavors.Len() == 0 || keepFlavors.Has(sym.Flavor()) {
   269  			out = append(out, sym)
   270  		}
   271  	}
   272  
   273  	return
   274  }
   275  
   276  // Anchor returns a markdown anchor name that can be used to link to some part
   277  // of this symbol's documentation from other parts of the doc.
   278  func (s *symbol) Anchor(sub ...string) string {
   279  	// Gitiles markdown doesn't like '_' in explicitly defined anchors for some
   280  	// reason. It also doesn't like runs of hyphens.
   281  	name := strings.ReplaceAll(s.FullName, "_", "-")
   282  	anchor := strings.Join(append([]string{name}, sub...), "-")
   283  	filtered := ""
   284  	last := '\x00'
   285  	for _, ch := range strings.Trim(anchor, "-") {
   286  		if ch == '-' && last == '-' {
   287  			continue
   288  		}
   289  		last = ch
   290  		filtered += string(ch)
   291  	}
   292  	return filtered
   293  }
   294  
   295  // InvocationSnippet returns a snippet showing how a function represented by
   296  // this symbol can be called.
   297  //
   298  // Like this:
   299  //
   300  //	luci.recipe(
   301  //	    # Required arguments.
   302  //	    name,
   303  //	    cipd_package,
   304  //
   305  //	    # Optional arguments.
   306  //	    cipd_version = None,
   307  //	    recipe = None,
   308  //
   309  //	    **kwargs,
   310  //	)
   311  //
   312  // This is apparently very non-trivial to generate using text/template while
   313  // keeping all spaces and newlines strict.
   314  func (s *symbol) InvocationSnippet() string {
   315  	var req, opt, variadric []string
   316  	for _, f := range s.Doc().Args() {
   317  		switch {
   318  		case strings.HasPrefix(f.Name, "*"):
   319  			variadric = append(variadric, f.Name)
   320  		case isRequiredField(f):
   321  			req = append(req, f.Name)
   322  		default:
   323  			opt = append(opt, fmt.Sprintf("%s = None", f.Name))
   324  		}
   325  	}
   326  
   327  	b := &strings.Builder{}
   328  
   329  	writeSection := func(section string, args []string) {
   330  		if len(args) != 0 {
   331  			b.WriteString(section)
   332  			for _, a := range args {
   333  				fmt.Fprintf(b, "    %s,\n", a)
   334  			}
   335  		}
   336  	}
   337  
   338  	fmt.Fprintf(b, "%s(", s.FullName)
   339  	if all := append(append(req, opt...), variadric...); len(all) <= 3 {
   340  		// Use a compact form when we have only very few arguments.
   341  		b.WriteString(strings.Join(all, ", "))
   342  	} else {
   343  		writeSection("\n    # Required arguments.\n", req)
   344  		writeSection("\n    # Optional arguments.\n", opt)
   345  		writeSection("\n", variadric)
   346  	}
   347  	fmt.Fprintf(b, ")")
   348  	return b.String()
   349  }
   350  
   351  // isRequiredField takes a field description and tries to figure out whether
   352  // this field is required.
   353  //
   354  // Does this by searching for "Required." suffix. Very robust.
   355  func isRequiredField(f docstring.Field) bool {
   356  	return strings.HasSuffix(f.Desc, "Required.")
   357  }