blob: 537e4568ff2ec00e2afc149a00d97d4306403cd2 [file] [log] [blame]
// Copyright 2023 The Shac Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:generate go run docregen_stdlib.go
package engine
import (
"context"
_ "embed"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"go.chromium.org/luci/starlark/docgen"
"go.chromium.org/luci/starlark/docgen/ast"
"go.fuchsia.dev/shac-project/shac/doc"
"google.golang.org/protobuf/encoding/prototext"
)
//go:embed docgen.mdt
var docgenTpl string
// Doc returns the documentation for a source file.
//
// src must be either a path to a source file or the string "stdlib".
func Doc(src string) (string, error) {
content := ""
isStdlib := false
if src == "stdlib" {
isStdlib = true
src = "stdlib.star"
content = doc.StdlibSrc
} else {
if strings.HasPrefix(src, "@") {
return "", errors.New("todo: implement @module")
}
if !strings.HasSuffix(src, ".star") {
return "", errors.New("invalid source file name, expecting .star suffix")
}
var err error
if src, err = filepath.Abs(src); err != nil {
return "", err
}
b, err := os.ReadFile(src)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("file %s not found", src)
}
return "", err
}
content = string(b)
}
tmpdir, err := os.MkdirTemp("", "shac")
if err != nil {
return "", err
}
d, err := genDoc(tmpdir, src, content, isStdlib)
if err2 := os.RemoveAll(tmpdir); err == nil {
err = err2
}
return d, err
}
func genDoc(tmpdir, src, content string, isStdlib bool) (string, error) {
// It's unfortunate that we parse the source file twice. We need to fix the
// upstream API.
m, err := ast.ParseModule(src, content, func(s string) (string, error) { return s, nil })
if err != nil {
return "", err
}
// Parse once to get all the global symbols and top level docstring.
var syms []ast.Node
for _, node := range m.Nodes {
if !strings.HasPrefix(node.Name(), "_") {
syms = append(syms, node)
}
}
// Load packages to get the exported symbols.
pkgMgr := NewPackageManager(tmpdir)
var packages map[string]fs.FS
root := filepath.Dir(src)
if !isStdlib {
var b []byte
if b, err = os.ReadFile(filepath.Join(root, "shac.textproto")); err == nil {
doc := Document{}
if err = prototext.Unmarshal(b, &doc); err != nil {
return "", err
}
if err = doc.Validate(); err != nil {
return "", err
}
if packages, err = pkgMgr.RetrievePackages(context.Background(), root, &doc); err != nil {
return "", err
}
} else if !errors.Is(err, fs.ErrNotExist) {
return "", err
} else {
// Still allow local access even if no shac.textproto is present.
packages = map[string]fs.FS{"__main__": os.DirFS(root)}
}
}
d := m.Doc()
parent := sourceKey{pkg: "__main__", relpath: src}
g := docgen.Generator{
Normalize: func(p, s string) (string, error) {
return normalize(parent, p, s)
},
Starlark: func(m string) (string, error) {
if m == src {
return content, nil
}
return getStarlark(packages, m)
},
}
// Appends all the global symbols to the template to render them.
gen := ""
// First, "load" the symbols.
for i, n := range syms {
gen += fmt.Sprintf("{{- $sym%d := Symbol %q %q }}", i, src, n.Name())
}
// Header and main comment if any.
if len(d) != 0 {
// If a module has a docstring, use the first line as the header.
gen += "# " + strings.TrimSpace(d)
} else {
// TODO(maruel): Maybe the absolute path? Or a module docstring?
gen += "# " + src
}
// Generate the table of content.
if len(syms) != 0 {
gen += "\n\n## Table of contents\n\n"
// TODO(maruel): Use "{{ template \"gen-toc\" }}"
for _, n := range syms {
name := n.Name()
if isStdlib {
// To avoid overriding stdlib built-in functions, their
// docstrings are attached to dummy functions of the same name
// but with a trailing underscore.
name = strings.TrimSuffix(name, "_")
}
// Anchor works here because top-level symbols are generally simple. It
// is brittle, especially with the different anchor generation algorithm
// between GitHub and Gitiles.
gen += "- [" + name + "](#" + name + ")\n"
}
}
// Each of the symbol.
for i := range syms {
gen += fmt.Sprintf("\n{{ template \"gen-any\" $sym%d}}\n", i)
}
b, err := g.Render(docgenTpl + gen)
return string(b), err
}
func normalize(parent sourceKey, p, s string) (string, error) {
skp, err := parseSourceKey(parent, p)
if err != nil {
return "", err
}
sks, err := parseSourceKey(skp, s)
return sks.String(), err
}
func getStarlark(packages map[string]fs.FS, m string) (string, error) {
pkg := "__main__"
relpath := ""
if strings.HasPrefix(m, "@") {
parts := strings.SplitN(m[1:], "//", 2)
pkg = parts[0]
relpath = parts[1]
} else if strings.HasPrefix(m, "//") {
relpath = m[2:]
}
ref := packages[pkg]
if ref == nil {
return "", fmt.Errorf("package %s not found", pkg)
}
d, err := ref.Open(relpath)
if errors.Is(err, fs.ErrNotExist) {
return "", errors.New("file not found")
}
if err != nil {
return "", err
}
b, err := io.ReadAll(d)
if err2 := d.Close(); err == nil {
err = err2
}
return string(b), err
}