commit b130b3dcd8063a8213b7c3edbc53107736624512 Author: Arpad Ryszka Date: Fri Aug 8 21:44:31 2025 +0200 init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbec715 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.bin +testdocs.go +.cover diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ffe1e4e --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +SOURCES = $(shell find . -name "*.go") +PREFIX ?= ~/bin + +default: build + +build: $(SOURCES) .bin + go build . + go build ./generate + go build -o .bin/docreflect ./cmd/docreflect + +.bin/docreflect: build + +.bin: + mkdir -p .bin + +check: $(SOURCES) .bin/docreflect + .bin/docreflect generate docreflect_test code.squareroundforest.org/arpio/docreflect/tests/src/testpackage > testdocs_test.go + go test -count 1 . ./generate + rm -f testdocs_test.go + +.cover: $(SOURCES) .bin/docreflect + .bin/docreflect generate docreflect_test code.squareroundforest.org/arpio/docreflect/tests/src/testpackage > testdocs_test.go + go test -count 1 -coverprofile .cover . ./generate + rm -f testdocs_test.go + +cover: .cover + go tool cover -func .cover + +showcover: .cover + go tool cover -html .cover + +fmt: $(SOURCES) + go fmt . ./generate ./cmd/docreflect + +install: .bin/docreflect + cp .bin/docreflect $(PREFIX) + +clean: + go clean ./... + rm -rf .bin + rm -f testdocs_test.go diff --git a/cmd/docreflect/main.go b/cmd/docreflect/main.go new file mode 100644 index 0000000..09403ba --- /dev/null +++ b/cmd/docreflect/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "code.squareroundforest.org/arpio/docreflect/generate" + "log" + "os" +) + +func main() { + args := os.Args[1:] + if args[0] == "generate" { + args = args[1:] + } + + packageName, args := args[0], args[1:] + if err := generate.GenerateRegistry(os.Stdout, packageName, args...); err != nil { + log.Fatalln(err) + } +} diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..10d8a3c --- /dev/null +++ b/docs.go @@ -0,0 +1,151 @@ +// Package docreflect returns the Go documentation for packages, types and functions. +// +// The Go documentation of packages is not available during runtime by default, so in order to use docreflect, the documentation +// for the selected packages and symbols needs to be generated during build time. To generate the documentation, see the +// docreflect/generate package or the docreflect generate command. +package docreflect + +import ( + "fmt" + "reflect" + "regexp" + "runtime" + "strings" +) + +var ( + registry = make(map[string]string) + funcParamsExp = regexp.MustCompile("^func[(]([a-zA-Z_][a-zA-Z0-9_]+(, [a-zA-Z_][a-zA-Z0-9_]+)*)?[)]$") +) + +func docs(p string) string { + return registry[p] +} + +func functionPath(v reflect.Value) string { + t := v.Type() + pp := t.PkgPath() + p := v.Pointer() + rf := runtime.FuncForPC(p) + n := rf.Name() + + // method names appear to have an -fm suffix: + np := strings.Split(n, ".") + npl := np[len(np)-1] + nplp := strings.Split(npl, "-") + if len(nplp) > 1 { + npl = nplp[0] + np[len(np)-1] = npl + n = strings.Join(np, ".") + } + + if strings.HasPrefix(n, pp) { + return n + } + + // sometimes we get packagename.FunctionName instead of the full import path: + np = strings.Split(n, ".") + if len(np) > 1 { + np = np[1:] + } + + return strings.Join(append([]string{pp}, np...), ".") +} + +func splitDocs(d string) (string, string) { + parts := strings.Split(d, "\n") + last := len(parts) - 1 + lastPart := parts[last] + + var params string + if funcParamsExp.MatchString(lastPart) { + parts = parts[:last] + params = lastPart + } + + return strings.Join(parts, "\n"), params +} + +func functionParams(d string) []string { + _, p := splitDocs(d) + if p == "" { + return nil + } + + p = p[len("func(") : len(p)-1] + return strings.Split(p, ", ") +} + +// Register is used by the code generated by the docreflect/generate package or the docreflect generate command to register +// the selected documentation during startup. Register is not meant to be used directly by importing packages. +func Register(gopath, docs string) { + registry[gopath] = docs +} + +// Docs returns the documentation for a package or a symbol in a package identified by its full go path. +func Docs(gopath string) string { + d := docs(gopath) + d, _ = splitDocs(d) + return d +} + +// Function returns the documentation for a package level function. +func Function(v reflect.Value) string { + if v.Kind() != reflect.Func { + return "" + } + + return Docs(functionPath(v)) +} + +// FunctionParams returns the list of the parameter names of a package level function. +func FunctionParams(v reflect.Value) []string { + if v.Kind() != reflect.Func { + return nil + } + + d := docs(functionPath(v)) + return functionParams(d) +} + +// Type returns the docuemntation for a package level type. +func Type(t reflect.Type) string { + p := fmt.Sprintf("%s.%s", t.PkgPath(), t.Name()) + return docs(p) +} + +// Field returns the docuemntation for a struct field. +func Field(t reflect.Type, fieldPath ...string) string { + if len(fieldPath) == 0 { + return "" + } + + if t.Kind() != reflect.Struct { + return "" + } + + p := strings.Join(append([]string{t.PkgPath(), t.Name()}, fieldPath...), ".") + println(p) + return docs(p) +} + +// Method returns the documentation for a type method. +func Method(t reflect.Type, name string) string { + if t.Kind() != reflect.Struct { + return "" + } + + p := fmt.Sprintf("%s.%s.%s", t.PkgPath(), t.Name(), name) + return docs(p) +} + +// MethodParams returns the list of the parameter names of a type method. +func MethodParams(t reflect.Type, name string) []string { + if t.Kind() != reflect.Struct { + return nil + } + + p := fmt.Sprintf("%s.%s.%s", t.PkgPath(), t.Name(), name) + d := docs(p) + return functionParams(d) +} diff --git a/docs_test.go b/docs_test.go new file mode 100644 index 0000000..4e7f524 --- /dev/null +++ b/docs_test.go @@ -0,0 +1,116 @@ +package docreflect_test + +import ( + "code.squareroundforest.org/arpio/docreflect" + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage" + "reflect" + "strings" + "testing" +) + +func Test(t *testing.T) { + t.Run("unregistered", func(t *testing.T) { + d := docreflect.Docs("foo/bar/baz.qux") + if d != "" { + t.Fatal() + } + }) + + t.Run("package", func(t *testing.T) { + d := docreflect.Docs("code.squareroundforest.org/arpio/docreflect/tests/src/testpackage") + if !strings.Contains(d, "Package testpackage") { + t.Fatal() + } + }) + + t.Run("function by path", func(t *testing.T) { + d := docreflect.Docs("code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedFunc") + if !strings.Contains(d, "ExportedFunc has documentation") { + t.Fatal() + } + + if strings.Contains(d, "func(p1, p2)") { + t.Fatal() + } + }) + + t.Run("function by value", func(t *testing.T) { + d := docreflect.Function(reflect.ValueOf(testpackage.ExportedFunc)) + if !strings.Contains(d, "ExportedFunc has documentation") { + t.Fatal(d) + } + + if strings.Contains(d, "func(p1, p2)") { + t.Fatal() + } + }) + + t.Run("function params", func(t *testing.T) { + d := docreflect.FunctionParams(reflect.ValueOf(testpackage.ExportedFunc)) + if len(d) != 2 { + t.Fatal(d) + } + + if d[0] != "p1" { + t.Fatal(d) + } + + if d[1] != "p2" { + t.Fatal(d) + } + }) + + t.Run("method as function value", func(t *testing.T) { + s := testpackage.ExportedType{} + m := s.Method + d := docreflect.Function(reflect.ValueOf(m)) + if !strings.Contains(d, "Method is a method of ExportedType") { + t.Fatal(d) + } + + p := docreflect.FunctionParams(reflect.ValueOf(m)) + if len(p) != 3 { + t.Fatal() + } + + if p[0] != "p1" || p[1] != "p2" || p[2] != "p3" { + t.Fatal() + } + }) + + t.Run("type", func(t *testing.T) { + s := testpackage.ExportedType{} + typ := reflect.TypeOf(s) + d := docreflect.Type(typ) + if !strings.Contains(d, "ExportedType has docs") { + t.Fatal() + } + }) + + t.Run("field", func(t *testing.T) { + s := testpackage.ExportedType{} + typ := reflect.TypeOf(s) + d := docreflect.Field(typ, "Foo") + if !strings.Contains(d, "Foo is a field") { + t.Fatal() + } + }) + + t.Run("method", func(t *testing.T) { + s := testpackage.ExportedType{} + typ := reflect.TypeOf(s) + d := docreflect.Method(typ, "Method") + if !strings.Contains(d, "Method is a method of ExportedType") { + t.Fatal() + } + + p := docreflect.MethodParams(typ, "Method") + if len(p) != 3 { + t.Fatal() + } + + if p[0] != "p1" || p[1] != "p2" || p[2] != "p3" { + t.Fatal() + } + }) +} diff --git a/generate/generate.go b/generate/generate.go new file mode 100644 index 0000000..812b52d --- /dev/null +++ b/generate/generate.go @@ -0,0 +1,536 @@ +package generate + +import ( + "fmt" + "go/ast" + "go/build" + "go/doc" + "go/parser" + "go/token" + "golang.org/x/mod/modfile" + "io" + "io/fs" + "os" + "path" + "runtime" + "slices" + "sort" + "strconv" + "strings" +) + +type options struct { + wd string + goroot string + gomod string + modules map[string]string +} + +func getGoroot() string { + gr := os.Getenv("GOROOT") + if gr != "" { + return gr + } + + return runtime.GOROOT() +} + +func modCache() string { + mc := os.Getenv("GOMODCACHE") + if mc != "" { + return mc + } + + gp := os.Getenv("GOPATH") + if gp == "" { + gp = path.Join(os.Getenv("HOME"), "go") + } + + mc = path.Join(gp, "pkg/mod") + return mc +} + +func findGoMod(dir string) (string, bool) { + p := path.Join(dir, "go.mod") + f, err := os.Stat(p) + if err == nil && !f.IsDir() { + return p, true + } + + if dir == "/" { + return "", false + } + + return findGoMod(path.Dir(dir)) +} + +func readGomod(wd string) (string, map[string]string) { + p, ok := findGoMod(wd) + if !ok { + return "", nil + } + + d, err := os.ReadFile(p) + if err != nil { + return "", nil + } + + m, err := modfile.Parse(p, d, nil) + if err != nil { + return "", nil + } + + mc := modCache() + dirs := make(map[string]string) + for _, dep := range m.Require { + pth := dep.Mod.String() + moduleDir := path.Join(mc, pth) + dirs[pth] = moduleDir + } + + name := m.Module.Mod.String() + dirs[name] = path.Dir(p) + return name, dirs +} + +func initOptions() options { + wd, _ := os.Getwd() + gr := getGoroot() + gomod, modules := readGomod(wd) + return options{ + wd: wd, + goroot: gr, + gomod: gomod, + modules: modules, + } +} + +func cleanPaths(gopath []string) []string { + var c []string + for _, pi := range gopath { + pi = path.Clean(pi) + c = append(c, pi) + } + + return c +} + +func splitGopath(p string) (string, string) { + parts := strings.Split(p, "/") + last := len(parts) - 1 + parts, lastPart := parts[:last], parts[last] + symbolParts := strings.Split(lastPart, ".") + pkg := symbolParts[0] + return strings.Join(append(parts, pkg), "/"), strings.Join(symbolParts[1:], ".") +} + +func packagePaths(p []string) []string { + var pp []string + m := make(map[string]bool) + for _, pi := range p { + ppi, _ := splitGopath(pi) + if m[ppi] { + continue + } + + pp = append(pp, ppi) + m[ppi] = true + } + + return pp +} + +func collectGoDirs(o options) []string { + var dirs []string + if o.goroot != "" { + dirs = append(dirs, path.Join(o.goroot, "src")) + dirs = append(dirs, path.Join(o.goroot, "src", "cmd")) + } + + return dirs +} + +func importPackages(o options, godirs, paths []string) ([]*build.Package, error) { + var pkgs []*build.Package + for _, p := range paths { + var found bool + for mod, modDir := range o.modules { + if !strings.HasPrefix(p, strings.Split(mod, "@")[0]) { + continue + } + + pkg, err := build.Import(p, modDir, build.ImportComment) + if err != nil || pkg == nil { + continue + } + + pkgs = append(pkgs, pkg) + found = true + break + } + + if found { + continue + } + + for _, d := range godirs { + pkg, err := build.Import(p, d, build.ImportComment) + if err != nil || pkg == nil { + continue + } + + pkgs = append(pkgs, pkg) + found = true + break + } + + if !found { + return nil, fmt.Errorf("failed to import package for %s", p) + } + } + + return pkgs, nil +} + +func parserInclude(pkg *build.Package) func(fs.FileInfo) bool { + return func(file fs.FileInfo) bool { + for _, fn := range pkg.GoFiles { + if fn == file.Name() { + return true + } + } + + return false + } +} + +func parsePackages(pkgs []*build.Package) (map[string][]*ast.Package, error) { + ppkgs := make(map[string][]*ast.Package) + for _, p := range pkgs { + fset := token.NewFileSet() + pm, err := parser.ParseDir(fset, p.Dir, parserInclude(p), parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("failed to parse package %s: %w", p.Name, err) + } + + for _, pp := range pm { + if pp != nil { + ppkgs[p.ImportPath] = append(ppkgs[p.ImportPath], pp) + } + } + } + + return ppkgs, nil +} + +func fixDocPackage(p doc.Package) doc.Package { + for _, t := range p.Types { + p.Consts = append(p.Consts, t.Consts...) + p.Vars = append(p.Vars, t.Vars...) + p.Funcs = append(p.Funcs, t.Funcs...) + } + + return p +} + +func merge(m ...map[string]string) map[string]string { + if len(m) == 1 { + return m[0] + } + + mm := make(map[string]string) + for key, value := range m[0] { + mm[key] = value + } + + mr := merge(m[1:]...) + for key, value := range mr { + mm[key] = value + } + + return mm +} + +func funcParams(f *doc.Func) string { + var paramNames []string + if f.Decl != nil && f.Decl.Type != nil && f.Decl.Type.Params != nil { + for _, p := range f.Decl.Type.Params.List { + for _, n := range p.Names { + if n != nil { + paramNames = append(paramNames, n.Name) + } + } + } + } + + return fmt.Sprintf("func(%s)", strings.Join(paramNames, ", ")) +} + +func funcDocs(f *doc.Func) string { + return fmt.Sprintf("%s\n%s", f.Doc, funcParams(f)) +} + +func symbolDocs(pkg *doc.Package, gopath string) (string, bool) { + _, s := splitGopath(gopath) + symbol := strings.Split(s, ".") + if len(symbol) == 1 { + for _, c := range pkg.Consts { + if c != nil && slices.Contains(c.Names, symbol[0]) { + return c.Doc, true + } + } + + for _, v := range pkg.Vars { + if v != nil && slices.Contains(v.Names, symbol[0]) { + return v.Doc, true + } + } + + for _, f := range pkg.Funcs { + if f == nil || f.Name != symbol[0] { + continue + } + + return funcDocs(f), true + } + } + + for _, t := range pkg.Types { + if t == nil || t.Name != symbol[0] { + continue + } + + if len(symbol) == 1 { + return t.Doc, true + } + + if t.Decl == nil || len(t.Decl.Specs) != 1 { + continue + } + + ts, ok := t.Decl.Specs[0].(*ast.TypeSpec) + if !ok { + continue + } + + str, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + + fieldPath := symbol[1:] + for len(fieldPath) > 0 { + var found bool + if str.Fields != nil { + for _, f := range str.Fields.List { + for _, fn := range f.Names { + if fn != nil { + if fn.Name != fieldPath[0] { + continue + } + + if len(fieldPath) == 1 { + if f.Doc == nil { + return "", true + } + + return f.Doc.Text(), true + } + + fstr, ok := f.Type.(*ast.StructType) + if !ok { + break + } + + found = true + str = fstr + fieldPath = fieldPath[1:] + break + } + } + } + } + + if !found { + break + } + } + + if len(symbol) != 2 { + return "", false + } + + methodName := symbol[1] + for _, m := range t.Methods { + if m.Name != methodName { + continue + } + + return funcDocs(m), true + } + } + + return "", false +} + +func symbolPath(packagePath string, name ...string) string { + return fmt.Sprintf("%s.%s", packagePath, strings.Join(name, ".")) +} + +func valueDocs(packagePath string, v []*doc.Value) map[string]string { + d := make(map[string]string) + for _, vi := range v { + if vi != nil { + for _, n := range vi.Names { + d[symbolPath(packagePath, n)] = vi.Doc + } + } + } + + return d +} + +func packageDocs(pkg *doc.Package) map[string]string { + d := make(map[string]string) + d[pkg.ImportPath] = pkg.Doc + d = merge(d, valueDocs(pkg.ImportPath, pkg.Consts)) + d = merge(d, valueDocs(pkg.ImportPath, pkg.Vars)) + for _, t := range pkg.Types { + if t != nil { + d[symbolPath(pkg.ImportPath, t.Name)] = t.Doc + } + + if t.Decl != nil && len(t.Decl.Specs) == 1 { + if ts, ok := t.Decl.Specs[0].(*ast.TypeSpec); ok { + if str, ok := ts.Type.(*ast.StructType); ok && str.Fields != nil { + for _, f := range str.Fields.List { + if f == nil || f.Doc == nil { + continue + } + + for _, fn := range f.Names { + if fn == nil { + continue + } + + d[symbolPath(pkg.ImportPath, t.Name, fn.Name)] = f.Doc.Text() + } + } + } + } + } + + for _, m := range t.Methods { + if m != nil { + doc := funcDocs(m) + d[symbolPath(pkg.ImportPath, t.Name, m.Name)] = doc + } + } + } + + for _, f := range pkg.Funcs { + if f != nil { + doc := funcDocs(f) + d[symbolPath(pkg.ImportPath, f.Name)] = doc + } + } + + return d +} + +func takeDocs(pkgs map[string][]*ast.Package, gopaths []string) (map[string]string, error) { + dm := make(map[string]string) + for _, gp := range gopaths { + pp, _ := splitGopath(gp) + isPackage := pp == gp + for _, pkg := range pkgs[pp] { + dpkg := doc.New(pkg, pp, doc.AllDecls|doc.PreserveAST) + *dpkg = fixDocPackage(*dpkg) + if isPackage { + pd := packageDocs(dpkg) + dm = merge(dm, pd) + continue + } + + sd, ok := symbolDocs(dpkg, gp) + if !ok { + return nil, fmt.Errorf("symbol not found: %s", gp) + } + + dm[gp] = sd + } + } + + return dm, nil +} + +func generate(o options, gopaths ...string) (map[string]string, error) { + gopaths = cleanPaths(gopaths) + ppaths := packagePaths(gopaths) + dirs := collectGoDirs(o) + pkgs, err := importPackages(o, dirs, ppaths) + if err != nil { + return nil, err + } + + ppkgs, err := parsePackages(pkgs) + if err != nil { + return nil, err + } + + return takeDocs(ppkgs, gopaths) +} + +func format(w io.Writer, pname string, docs map[string]string) error { + var err error + printf := func(f string, a ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintf(w, f, a...) + } + + println := func(a ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintln(w, a...) + } + + printf("package %s\n", pname) + println("import \"code.squareroundforest.org/arpio/docreflect\"") + println("func init() {") + + var paths []string + for path := range docs { + paths = append(paths, path) + } + + sort.Strings(paths) + for _, path := range paths { + doc := docs[path] + path, doc = strconv.Quote(path), strconv.Quote(doc) + printf("docreflect.Register(%s, %s)\n", path, doc) + } + + println("}") + return err +} + +// GenerateRegistry generates a Go code file to the output, including a package init function that +// will register the documentation of the declarations specified by their gopath. +func GenerateRegistry(w io.Writer, outputPackageName string, gopath ...string) error { + o := initOptions() + d, err := generate(o, gopath...) + if err != nil { + return err + } + + if err := format(w, outputPackageName, d); err != nil { + return err + } + + return nil +} diff --git a/generate/generate_test.go b/generate/generate_test.go new file mode 100644 index 0000000..3fc715f --- /dev/null +++ b/generate/generate_test.go @@ -0,0 +1,358 @@ +package generate + +import ( + "bytes" + "code.squareroundforest.org/arpio/notation" + "fmt" + "os" + "path" + "runtime" + "strings" + "testing" +) + +func check(c ...string) map[string]string { + m := make(map[string]string) + for i := 0; i < len(c); i += 2 { + name := c[i] + + var value string + if i < len(c)-1 { + value = c[i+1] + } + + m[name] = value + } + + return m +} + +func testGenerate(check map[string]string, errstr string, o options, gopath ...string) func(t *testing.T) { + return func(t *testing.T) { + d, err := generate(o, gopath...) + if errstr != "" { + if err == nil { + t.Fatal("failed to fail") + } + + if !strings.Contains(err.Error(), errstr) { + t.Fatal("unexpected error", err) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + var failed bool + for name, expect := range check { + dn, ok := d[name] + if !ok { + failed = true + break + } + + if !strings.Contains(dn, expect) { + failed = true + break + } + } + + if failed { + t.Log("failed to get the right documentation") + t.Log("expected matches:") + t.Log(notation.Sprint(check)) + t.Log("documetation got:") + t.Log(notation.Sprint(d)) + t.Fatal() + } + } +} + +func TestGenerate(t *testing.T) { + wd := path.Clean(path.Join(os.Getenv("PWD"), "..")) + h := os.Getenv("HOME") + o := options{ + wd: wd, + goroot: runtime.GOROOT(), + gomod: "code.squareroundforest.org/arpio/docreflect", + modules: map[string]string{ + "golang.org/x/mod@v0.27.0": path.Join(h, "go", "pkg/mod", "golang.org/x/mod@v0.27.0"), + "code.squareroundforest.org/arpio/notation@v0.0.0-20241225183158-af3bd591a174": path.Join( + h, "go", "pkg/mod", "code.squareroundforest.org/arpio/notation@v0.0.0-20241225183158-af3bd591a174", + ), + "code.squareroundforest.org/arpio/docreflect": wd, + }, + } + + t.Run("with modules", func(t *testing.T) { + t.Run( + "stdlib", + testGenerate(check("strings.Join", "Join concatenates", "strings.Join", "func(elems, sep)"), "", o, "strings.Join"), + ) + + notationPrintln := "code.squareroundforest.org/arpio/notation.Println" + t.Run( + "imported module", + testGenerate( + check( + notationPrintln, + "Println prints", + notationPrintln, + "func(v)", + ), + "", + o, + notationPrintln, + ), + ) + + localDocs := "code.squareroundforest.org/arpio/docreflect.Docs" + t.Run( + "local", + testGenerate(check(localDocs, "Docs returns the documentation for"), "", o, localDocs), + ) + + packagePath := "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage" + t.Run( + "package", + testGenerate( + check( + packagePath, "Package testpackage is a test package", + fmt.Sprintf("%s.%s", packagePath, "ExportedType"), "ExportedType has docs", + ), + "", + o, + packagePath, + ), + ) + + t.Run("symbol", func(t *testing.T) { + t.Run("type", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType", + "ExportedType has docs", + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType2", + "", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType", + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType2", + )) + + t.Run("const", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.C1", + "C1 is a const", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.C1", + )) + + t.Run("const grouped", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.C3", + "Cx is a const", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.C3", + )) + + t.Run("var", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.V1", + "V1 is a var", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.V1", + )) + + t.Run("var grouped", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.V3", + "Vx is a var", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.V3", + )) + + t.Run("func", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedFunc", + "ExportedFunc has documentation", + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.New", + "New create a new instance of ExportedType", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.New", + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedFunc", + )) + }) + + t.Run("field", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Foo", + "Foo is a field", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Foo", + )) + + t.Run("method", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Method", + "Method is a method of ExportedType", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Method", + )) + + t.Run("inline struct type expression", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Bar", + "Bar is an inline struct type expression", + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Bar.Baz", + "Baz is another field", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Bar", + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Bar.Baz", + )) + + t.Run("unexported symbol", testGenerate( + check( + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.unexportedFunc", + "unexportedFunc can have documentation", + ), + "", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.unexportedFunc", + )) + + mainFunc := "code.squareroundforest.org/arpio/docreflect/tests/src/command.main" + t.Run("main package", testGenerate(check(mainFunc, "main func"), "", o, mainFunc)) + }) + + t.Run("errors", func(t *testing.T) { + t.Run("package not found", testGenerate(nil, "package", o, "foo.bar.baz/qux")) + t.Run( + "symbol not found", + testGenerate(nil, "symbol", o, "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.Qux"), + ) + + t.Run( + "field not found", + testGenerate( + nil, + "symbol", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Qux", + ), + ) + + t.Run( + "field not found, second level", + testGenerate( + nil, + "symbol", + o, + "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType.Foo.Qux", + ), + ) + + t.Run( + "method not found", + testGenerate(nil, "symbol", o, "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.ExportedType2.Qux"), + ) + + t.Run( + "invalid path", + testGenerate(nil, "foo", o, "./foo/bar"), + ) + + t.Run( + "non-existent module path", + testGenerate(nil, "package", o, "code.squareroundforest.org/arpio/notation/foo.Foo"), + ) + + t.Run( + "parse failed, syntax error", + testGenerate(nil, "package", o, "code.squareroundforest.org/arpio/docreflect/tests/src/syntaxerror"), + ) + + t.Run( + "no type spec", + testGenerate(nil, "symbol", o, "code.squareroundforest.org/arpio/docreflect/tests/src/testpackage.Foo.Qux"), + ) + }) + + t.Run("init options", func(t *testing.T) { + o := initOptions() + if o.wd != os.Getenv("PWD") { + t.Fatal("wd") + } + + if o.goroot != runtime.GOROOT() { + t.Fatal("goroot") + } + + if o.gomod != "code.squareroundforest.org/arpio/docreflect" { + t.Fatal("gomod") + } + + for _, module := range []string{ + "code.squareroundforest.org/arpio/notation", + "golang.org/x/mod", + } { + var found bool + for key := range o.modules { + if strings.Contains(key, module) { + found = true + } + } + + if !found { + t.Fatal("gomod") + } + } + }) +} + +func TestFormat(t *testing.T) { + // header + // import + // few items + // escaping + + b := bytes.NewBuffer(nil) + d := map[string]string{ + "foo": "bar", + "baz": "qux", + } + + if err := format(b, "testpackage", d); err != nil { + t.Fatal(err) + } + + o := b.String() + if o != `package testpackage +import "code.squareroundforest.org/arpio/docreflect" +func init() { +docreflect.Register("baz", "qux") +docreflect.Register("foo", "bar") +} +` { + t.Fatal() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d39ac3 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module code.squareroundforest.org/arpio/docreflect + +go 1.24.3 + +require ( + code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 // indirect + golang.org/x/mod v0.27.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9978212 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 h1:DKMSagVY3uyRhJ4ohiwQzNnR6CWdVKLkg97A8eQGxQU= +code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6db8e93 --- /dev/null +++ b/readme.md @@ -0,0 +1,30 @@ +# Docreflect + +Library and command to help accessing go doc comments during runtime. + +Go doc comments are not accessible during runtime vi reflection. To make them avaiable during runtime, we need to capture them +during build time. The docreflect command, or the docreflect/generate library package, fetches the go doc comments of the specified +declarations, and generates Go code that registers the docs for every declaration. Code that includes the generated initialization +code, will have the docs accessible via the top level docreflect package methods. + +Declarations: + +- the declarations must be absolute Go paths +- when passing in the import path of a package, all the top level symbols of the package, plus the struct fields and methods of the + top level types will be included +- the package documentation can be fetched using docreflect.Docs("absolute/import/path/of/package") +- when passing in the import path of only the selected symbols, the rest of the package level symbols will be ignroed + +Library documentation: https://godocs.io/code.squareroundforest.org/arpio/docreflect + +To insall the docreflect command, run: + +``` +make install +``` + +Usage of the docreflect command: + +``` +docreflect generate --package-name mypackage coderepos.org/jdoe/mypackage > docreflect.go +``` diff --git a/tests/src/command/main.go b/tests/src/command/main.go new file mode 100644 index 0000000..efd621a --- /dev/null +++ b/tests/src/command/main.go @@ -0,0 +1,5 @@ +package main + +// main func +func main() { +} diff --git a/tests/src/syntaxerror/syntaxerror.go b/tests/src/syntaxerror/syntaxerror.go new file mode 100644 index 0000000..dd023be --- /dev/null +++ b/tests/src/syntaxerror/syntaxerror.go @@ -0,0 +1,3 @@ +package syntaxerror + +type struct foo diff --git a/tests/src/testpackage/test.go b/tests/src/testpackage/test.go new file mode 100644 index 0000000..00f5546 --- /dev/null +++ b/tests/src/testpackage/test.go @@ -0,0 +1,64 @@ +// Package testpackage is a test package +// +package testpackage + +import "fmt" + +// unexportedType can have docs, too +type unexportedType int + +// ExportedType has docs +type ExportedType struct { + + // Foo is a field + Foo int + + // Bar is an inline struct type expression + Bar struct { + + // Baz is another field + Baz int + } +} + +type Foo = ExportedType + +type ExportedType2 struct{} + +// C1 is a const +const C1 = 42 + +// Cx is a const +const ( + C2 = 1 << iota + C3 +) + +// V1 is a var +var V1 = C1 + +// Vx is a var +var ( + V2 = C2 + V3 = C3 +) + +// New create a new instance of ExportedType +func New(p1, p2 int, p3 string) ExportedType { + return ExportedType{} +} + +// Method is a method of ExportedType +func (t ExportedType) Method(p1, p2 int, p3 string) (o string, err error) { + return fmt.Sprint(t.Bar.Baz), nil +} + +// ExportedFunc has documentation +func ExportedFunc(p1, p2 int) int { + return p1 + p2 +} + +// unexportedFunc can have documentation +func unexportedFunc(p1, p2 int) int { + return p1 + p2 +}