godoc.go

v1.3.7
Doc Versions Source
1
package godoc
2
3
import (
4
	"go/ast"
5
	"go/doc"
6
	"go/doc/comment"
7
	"go/parser"
8
	"go/token"
9
	"path/filepath"
10
	"sort"
11
	"strings"
12
13
	"golang.org/x/mod/modfile"
14
15
	"go.bigb.es/curator/internal/git"
16
)
17
18
// PackageDoc holds parsed documentation for a Go package.
19
type PackageDoc struct {
20
	ImportPath string
21
	Name       string
22
	Synopsis   string
23
	Doc        string // HTML-rendered doc comment
24
	Consts     []ValueDoc
25
	Vars       []ValueDoc
26
	Funcs      []FuncDoc
27
	Types      []TypeDoc
28
	Files      []string
29
	Imports    map[string]string // alias → import path (e.g., "http" → "net/http")
30
	DepVersions map[string]string // module path → version from go.mod (e.g., "golang.org/x/net" → "v0.50.0")
31
32
	// Module-level info (populated by caller).
33
	Module      string
34
	Version     string
35
	Versions    []string
36
	SubPackages []SubPkgSummary
37
}
38
39
// ValueDoc represents a const or var group.
40
type ValueDoc struct {
41
	Names []string
42
	Doc   string // HTML
43
	Decl  string // source declaration
44
}
45
46
// FuncDoc represents a function.
47
type FuncDoc struct {
48
	Name       string
49
	Doc        string // HTML
50
	Decl       string // signature
51
	Recv       string // receiver type, empty for top-level functions
52
	ShortDecl  string // one-line signature for index
53
	SourceFile string // file containing the declaration
54
	SourceLine int    // line number
55
}
56
57
// TypeDoc represents a type with its methods and associated functions.
58
type TypeDoc struct {
59
	Name       string
60
	Doc        string // HTML
61
	Decl       string // type declaration source
62
	Consts     []ValueDoc
63
	Vars       []ValueDoc
64
	Funcs      []FuncDoc // associated constructors
65
	Methods    []FuncDoc
66
	SourceFile string // file containing the declaration
67
	SourceLine int    // line number
68
}
69
70
// SubPkgSummary describes a sub-package for the module overview.
71
type SubPkgSummary struct {
72
	RelPath    string // relative path from module root
73
	ImportPath string
74
	Synopsis   string
75
}
76
77
// ParsePackage reads Go source files from a git revision and produces documentation.
78
func ParsePackage(gitCache *git.Cache, repoPath, rev, pkgDir, importPath string) (*PackageDoc, error) {
79
	files, err := git.ReadDir(repoPath, rev, pkgDir)
80
	if err != nil {
81
		return nil, err
82
	}
83
84
	if len(files) == 0 {
85
		return nil, nil
86
	}
87
88
	fset := token.NewFileSet()
89
	var astFiles []*ast.File
90
	var fileNames []string
91
92
	for name, src := range files {
93
		fileNames = append(fileNames, name)
94
		f, err := parser.ParseFile(fset, name, src, parser.ParseComments)
95
		if err != nil {
96
			continue // skip files with parse errors
97
		}
98
		astFiles = append(astFiles, f)
99
	}
100
101
	if len(astFiles) == 0 {
102
		return nil, nil
103
	}
104
105
	sort.Strings(fileNames)
106
107
	pkg, err := doc.NewFromFiles(fset, astFiles, importPath)
108
	if err != nil {
109
		return nil, err
110
	}
111
112
	printer := pkg.Printer()
113
114
	// Extract import aliases from all files.
115
	imports := make(map[string]string)
116
	for _, f := range astFiles {
117
		for _, imp := range f.Imports {
118
			path := strings.Trim(imp.Path.Value, `"`)
119
			var alias string
120
			if imp.Name != nil {
121
				alias = imp.Name.Name
122
			} else {
123
				// Default alias is the last path element.
124
				if i := strings.LastIndex(path, "/"); i >= 0 {
125
					alias = path[i+1:]
126
				} else {
127
					alias = path
128
				}
129
			}
130
			if alias != "." && alias != "_" {
131
				imports[alias] = path
132
			}
133
		}
134
	}
135
136
	// Parse go.mod for dependency versions.
137
	depVersions := parseGoModVersions(repoPath, rev)
138
139
	result := &PackageDoc{
140
		ImportPath:  importPath,
141
		Name:        pkg.Name,
142
		Synopsis:    doc.Synopsis(pkg.Doc),
143
		Doc:         renderDocHTML(printer, pkg.Parser().Parse(pkg.Doc)),
144
		Files:       fileNames,
145
		Imports:     imports,
146
		DepVersions: depVersions,
147
	}
148
149
	// Constants.
150
	for _, v := range pkg.Consts {
151
		result.Consts = append(result.Consts, valueDoc(fset, printer, pkg.Parser(), v))
152
	}
153
154
	// Variables.
155
	for _, v := range pkg.Vars {
156
		result.Vars = append(result.Vars, valueDoc(fset, printer, pkg.Parser(), v))
157
	}
158
159
	// Functions.
160
	for _, f := range pkg.Funcs {
161
		result.Funcs = append(result.Funcs, funcDoc(fset, printer, pkg.Parser(), f))
162
	}
163
164
	// Types.
165
	for _, t := range pkg.Types {
166
		td := TypeDoc{
167
			Name: t.Name,
168
			Doc:  renderDocHTML(printer, pkg.Parser().Parse(t.Doc)),
169
			Decl: formatNode(fset, t.Decl),
170
		}
171
		if t.Decl != nil {
172
			pos := fset.Position(t.Decl.Pos())
173
			td.SourceFile = filepath.Base(pos.Filename)
174
			td.SourceLine = pos.Line
175
		}
176
		for _, v := range t.Consts {
177
			td.Consts = append(td.Consts, valueDoc(fset, printer, pkg.Parser(), v))
178
		}
179
		for _, v := range t.Vars {
180
			td.Vars = append(td.Vars, valueDoc(fset, printer, pkg.Parser(), v))
181
		}
182
		for _, f := range t.Funcs {
183
			td.Funcs = append(td.Funcs, funcDoc(fset, printer, pkg.Parser(), f))
184
		}
185
		for _, m := range t.Methods {
186
			td.Methods = append(td.Methods, funcDoc(fset, printer, pkg.Parser(), m))
187
		}
188
		result.Types = append(result.Types, td)
189
	}
190
191
	return result, nil
192
}
193
194
// ListSubPackages discovers sub-packages in a module.
195
func ListSubPackages(gitCache *git.Cache, repoPath, rev, modulePath string) ([]SubPkgSummary, error) {
196
	dirs, err := git.ListSubDirs(repoPath, rev, "")
197
	if err != nil {
198
		return nil, err
199
	}
200
201
	var subs []SubPkgSummary
202
	fset := token.NewFileSet()
203
204
	for _, dir := range dirs {
205
		files, err := git.ReadDir(repoPath, rev, dir)
206
		if err != nil {
207
			continue
208
		}
209
210
		// Parse just enough to get package name and synopsis.
211
		var synopsis string
212
		for _, src := range files {
213
			f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
214
			if err != nil {
215
				continue
216
			}
217
			if f.Doc != nil {
218
				synopsis = doc.Synopsis(f.Doc.Text())
219
				break
220
			}
221
		}
222
223
		subs = append(subs, SubPkgSummary{
224
			RelPath:    dir,
225
			ImportPath: modulePath + "/" + dir,
226
			Synopsis:   synopsis,
227
		})
228
	}
229
230
	return subs, nil
231
}
232
233
func valueDoc(fset *token.FileSet, printer *comment.Printer, docParser *comment.Parser, v *doc.Value) ValueDoc {
234
	return ValueDoc{
235
		Names: v.Names,
236
		Doc:   renderDocHTML(printer, docParser.Parse(v.Doc)),
237
		Decl:  formatNode(fset, v.Decl),
238
	}
239
}
240
241
func funcDoc(fset *token.FileSet, printer *comment.Printer, docParser *comment.Parser, f *doc.Func) FuncDoc {
242
	recv := ""
243
	if f.Recv != "" {
244
		recv = f.Recv
245
	}
246
	fd := FuncDoc{
247
		Name:      f.Name,
248
		Doc:       renderDocHTML(printer, docParser.Parse(f.Doc)),
249
		Decl:      formatNode(fset, f.Decl),
250
		Recv:      recv,
251
		ShortDecl: shortFuncDecl(f),
252
	}
253
	if f.Decl != nil {
254
		pos := fset.Position(f.Decl.Pos())
255
		fd.SourceFile = filepath.Base(pos.Filename)
256
		fd.SourceLine = pos.Line
257
	}
258
	return fd
259
}
260
261
func renderDocHTML(printer *comment.Printer, d *comment.Doc) string {
262
	raw := string(printer.HTML(d))
263
	// Post-process: highlight code blocks inside <pre> tags.
264
	return highlightDocPreBlocks(raw)
265
}
266
267
// highlightDocPreBlocks finds <pre>...</pre> blocks in doc HTML and
268
// applies Go syntax highlighting to their contents.
269
func highlightDocPreBlocks(html string) string {
270
	var b strings.Builder
271
	for {
272
		preStart := strings.Index(html, "<pre>")
273
		if preStart < 0 {
274
			b.WriteString(html)
275
			break
276
		}
277
		preEnd := strings.Index(html[preStart:], "</pre>")
278
		if preEnd < 0 {
279
			b.WriteString(html)
280
			break
281
		}
282
		preEnd += preStart
283
284
		// Write everything before <pre>.
285
		b.WriteString(html[:preStart])
286
287
		// Extract content between <pre> and </pre>.
288
		content := html[preStart+5 : preEnd]
289
290
		// Unescape HTML entities for the highlighter (the printer HTML-escapes content).
291
		content = strings.ReplaceAll(content, "&amp;", "&")
292
		content = strings.ReplaceAll(content, "&lt;", "<")
293
		content = strings.ReplaceAll(content, "&gt;", ">")
294
		content = strings.ReplaceAll(content, "&#34;", "\"")
295
		content = strings.ReplaceAll(content, "&quot;", "\"")
296
297
		highlighted := HighlightDecl(content)
298
299
		b.WriteString("<pre>")
300
		b.WriteString(highlighted)
301
		b.WriteString("</pre>")
302
303
		html = html[preEnd+6:] // skip past </pre>
304
	}
305
	return b.String()
306
}
307
308
func shortFuncDecl(f *doc.Func) string {
309
	s := "func "
310
	if f.Recv != "" {
311
		s += "(" + f.Recv + ") "
312
	}
313
	s += f.Name + formatFuncSignature(f)
314
	return s
315
}
316
317
func formatFuncSignature(f *doc.Func) string {
318
	if f.Decl == nil || f.Decl.Type == nil {
319
		return "()"
320
	}
321
322
	var b strings.Builder
323
	b.WriteString("(")
324
325
	if f.Decl.Type.Params != nil {
326
		for i, p := range f.Decl.Type.Params.List {
327
			if i > 0 {
328
				b.WriteString(", ")
329
			}
330
			for j, name := range p.Names {
331
				if j > 0 {
332
					b.WriteString(", ")
333
				}
334
				b.WriteString(name.Name)
335
			}
336
			if len(p.Names) > 0 {
337
				b.WriteString(" ")
338
			}
339
			b.WriteString(formatExpr(p.Type))
340
		}
341
	}
342
	b.WriteString(")")
343
344
	if f.Decl.Type.Results != nil && len(f.Decl.Type.Results.List) > 0 {
345
		results := f.Decl.Type.Results.List
346
		if len(results) == 1 && len(results[0].Names) == 0 {
347
			b.WriteString(" " + formatExpr(results[0].Type))
348
		} else {
349
			b.WriteString(" (")
350
			for i, r := range results {
351
				if i > 0 {
352
					b.WriteString(", ")
353
				}
354
				for j, name := range r.Names {
355
					if j > 0 {
356
						b.WriteString(", ")
357
					}
358
					b.WriteString(name.Name)
359
				}
360
				if len(r.Names) > 0 {
361
					b.WriteString(" ")
362
				}
363
				b.WriteString(formatExpr(r.Type))
364
			}
365
			b.WriteString(")")
366
		}
367
	}
368
369
	return b.String()
370
}
371
372
// parseGoModVersions reads go.mod from the repo and returns a map of
373
// module path → version for all require directives.
374
// Returns nil on any error (missing go.mod, parse failure, etc.).
375
func parseGoModVersions(repoPath, rev string) map[string]string {
376
	data, err := git.ReadFile(repoPath, rev, "go.mod")
377
	if err != nil {
378
		return nil
379
	}
380
	f, err := modfile.ParseLax("go.mod", data, nil)
381
	if err != nil {
382
		return nil
383
	}
384
385
	versions := make(map[string]string, len(f.Require))
386
	for _, req := range f.Require {
387
		versions[req.Mod.Path] = req.Mod.Version
388
	}
389
390
	// Store the Go version for stdlib linking.
391
	if f.Go != nil {
392
		versions["go"] = f.Go.Version
393
	}
394
395
	return versions
396
}
397

Source Files