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 }