package docgen import ( "os" "path/filepath" "regexp" "strconv" "strings" ) // --------------------------------------------------------------------------- // Go source extraction // --------------------------------------------------------------------------- var ( // net/http style registrations, including the Go 1.22 method-prefixed form // mux.HandleFunc("GET /health", h). The literal is captured whole and // validated in parseRoute, which is where the method prefix is split off. routeHandleCall = regexp.MustCompile(`\b(?:Handle|HandleFunc)\(\s*"([^"]+)"\s*(?:,\s*([A-Za-z0-9_.]+))?`) // Route tables that pass the method as an http.MethodXxx constant, as // go-guess does: register(router, spec, false, http.MethodGet, "/api/health", h) routeMethodConst = regexp.MustCompile(`\bhttp\.Method([A-Za-z]+)\s*,\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`) // Router-value registrations: r.Get("/api/x", h.X) routeRouterCall = regexp.MustCompile(`\b\w+\.(Get|Post|Put|Patch|Delete|Head|Options|Trace)\(\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`) // "GET /api/v1/x" as a literal argument. methodPrefix = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S.*)$`) // flag.String("name", default, "usage") flagCall = regexp.MustCompile(`flag\.(\w+)\(\s*"([^"]+)"\s*(?:,\s*([^,)]+))?\s*(?:,\s*"([^"]*)")?`) // os.Getenv("X") / os.LookupEnv("X") envCall = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*"([A-Z][A-Z0-9_]*)"`) // Port literals anywhere in the project. portLiteral = regexp.MustCompile(`(?:\bPORT\b|\bport\b|EXPOSE)[^0-9]{0,20}([0-9]{2,5})\b`) testFuncDecl = regexp.MustCompile(`(?m)^func\s+(Test|Benchmark|Fuzz)\w*\(`) ) // analyzeGoFile pulls routes, flags and environment variables out of one source // file. Each extractor is independent: a syntax oddity in one never affects the // others because these are textual scans, not a type-checked AST walk. // // Test files are excluded. They build requests with httptest.NewRequest, which // looks exactly like a route registration, and a route table is never declared // in a _test.go file. func analyzeGoFile(p *Project, file scannedFile) { if strings.HasSuffix(file.rel, "_test.go") { return } source := file.text() if source == "" { return } for _, match := range routeHandleCall.FindAllStringSubmatch(source, -1) { if route := parseRoute(match[1], match[2]); route.Path != "" { p.Routes = append(p.Routes, route) } } for _, match := range routeMethodConst.FindAllStringSubmatch(source, -1) { p.Routes = append(p.Routes, Route{ Method: strings.ToUpper(match[1]), Path: match[2], Handler: handlerName(match[3]), }) } for _, match := range routeRouterCall.FindAllStringSubmatch(source, -1) { p.Routes = append(p.Routes, Route{ Method: strings.ToUpper(match[1]), Path: match[2], Handler: handlerName(match[3]), }) } for _, match := range flagCall.FindAllStringSubmatch(source, -1) { p.Flags = append(p.Flags, Flag{ Name: match[2], Type: strings.ToLower(match[1]), Default: unquote(strings.TrimSpace(match[3])), Usage: strings.TrimSpace(match[4]), }) } for _, match := range envCall.FindAllStringSubmatch(source, -1) { p.EnvVars = append(p.EnvVars, EnvVar{Name: match[1]}) } } // parseRoute splits an optional method prefix and normalises the path. A // literal that is not a route — a query key, a header name, a MIME type — is // rejected by requiring the path to look like one. func parseRoute(raw, handler string) Route { method, path := "", strings.TrimSpace(raw) if match := methodPrefix.FindStringSubmatch(raw); match != nil { method, path = match[1], strings.TrimSpace(match[2]) } if !strings.HasPrefix(path, "/") { return Route{} } return Route{Method: method, Path: path, Handler: handlerName(handler)} } // handlerNames that are never useful in a route table: they are literal // arguments, receivers or framework values rather than handler functions. var noiseHandlers = map[string]struct{}{ "nil": {}, "true": {}, "false": {}, "spec": {}, "router": {}, "mux": {}, "r": {}, "g": {}, "chi": {}, "group": {}, "protected": {}, "public": {}, "api": {}, "v1": {}, "sub": {}, "app": {}, "server": {}, "srv": {}, "ctx": {}, "c": {}, "w": {}, "req": {}, "request": {}, "http": {}, } // handlerName reduces a handler expression to a readable name, dropping the // receiver and rejecting anything that is not plainly a function reference. func handlerName(handler string) string { handler = strings.TrimSpace(handler) if handler == "" { return "" } if i := strings.LastIndex(handler, "."); i >= 0 { handler = handler[i+1:] } handler = strings.TrimSuffix(handler, ".func1") if _, noise := noiseHandlers[handler]; noise { return "" } if handler == "HandlerFunc" || strings.Contains(handler, "(") { return "" } return handler } // --------------------------------------------------------------------------- // go.mod // --------------------------------------------------------------------------- var ( modLine = regexp.MustCompile(`^module\s+(\S+)`) goVersionLine = regexp.MustCompile(`^go\s+(\S+)`) requireLine = regexp.MustCompile(`^\s*(\S+)\s+(v\S+)(.*)$`) ) // parseGoMod reads a go.mod outside any require block. func parseGoMod(rel, source string) Module { module := Module{File: rel} inBlock := false for _, raw := range strings.Split(source, "\n") { line := strings.TrimRight(raw, " \t") trimmed := strings.TrimSpace(line) switch { case strings.HasPrefix(trimmed, "module "): if module.Path == "" { module.Path = strings.TrimSpace(strings.TrimPrefix(trimmed, "module ")) } case strings.HasPrefix(trimmed, "go "): if module.GoVersion == "" { if match := goVersionLine.FindStringSubmatch(trimmed); match != nil { module.GoVersion = match[1] } } case trimmed == "require (": inBlock = true case inBlock && trimmed == ")": inBlock = false case inBlock && trimmed != "" && !strings.HasPrefix(trimmed, "//"): match := requireLine.FindStringSubmatch(line) if match != nil { addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect")) } case strings.HasPrefix(trimmed, "require ") && !inBlock: rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "require ")) if match := requireLine.FindStringSubmatch(" " + rest); match != nil { addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect")) } } } return module } func addDependency(module *Module, path, version string, indirect bool) { if path == "" || strings.HasPrefix(path, "//") { return } if indirect { module.Indirect++ return } module.Direct = append(module.Direct, Dependency{Path: path, Version: version}) } // --------------------------------------------------------------------------- // Makefile // --------------------------------------------------------------------------- var makeTargetLine = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*):`) // makeTargets extracts invocable targets, skipping .PHONY declarations and // variable assignments. func makeTargets(source string) []string { var targets []string phony := map[string]struct{}{} for _, raw := range strings.Split(source, "\n") { if strings.HasPrefix(raw, "\t") || strings.HasPrefix(raw, " ") { continue } line := strings.TrimRight(raw, " \t") if strings.HasPrefix(line, ".PHONY") { for _, name := range strings.Fields(line) { if name == ".PHONY:" || name == ".PHONY" { continue } phony[strings.TrimSuffix(name, ":")] = struct{}{} } continue } match := makeTargetLine.FindStringSubmatch(line) if match == nil { continue } name := match[1] if strings.HasPrefix(name, ".") { continue } if _, isPhony := phony[name]; isPhony { continue } targets = append(targets, name) } return targets } // --------------------------------------------------------------------------- // Docker // --------------------------------------------------------------------------- var ( dockerFrom = regexp.MustCompile(`(?mi)^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?`) dockerExp = regexp.MustCompile(`(?mi)^\s*EXPOSE\s+(\S+)`) ) func parseDockerfile(rel, source string) *DockerInfo { info := &DockerInfo{File: rel} for _, match := range dockerFrom.FindAllStringSubmatch(source, -1) { info.Bases = append(info.Bases, match[1]) if match[2] != "" { info.Stages = append(info.Stages, match[2]) } } for _, match := range dockerExp.FindAllStringSubmatch(source, -1) { info.Exposed = append(info.Exposed, match[1]) } // Order matters: the first FROM is the build stage and the last is the // runtime image, so these lists are deduplicated but never sorted. info.Bases = dedupeInOrder(info.Bases) info.Stages = dedupeInOrder(info.Stages) info.Exposed = dedupeInOrder(info.Exposed) return info } // --------------------------------------------------------------------------- // docker-compose // --------------------------------------------------------------------------- // parseCompose reads service names, images and published ports out of a compose // file. It is a line scanner rather than a YAML parser: compose files in these // projects are consistently indented two spaces per level, and avoiding a YAML // dependency keeps the module dependency-free. func parseCompose(rel, source string) []ComposeService { var ( services []ComposeService inBlock bool current int = -1 ) for _, raw := range strings.Split(source, "\n") { line := strings.TrimRight(raw, " \t") trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } indent := len(line) - len(strings.TrimLeft(line, " ")) if indent == 0 { inBlock = trimmed == "services:" current = -1 continue } if !inBlock { continue } if indent == 2 && strings.HasSuffix(trimmed, ":") { services = append(services, ComposeService{Name: strings.TrimSuffix(trimmed, ":"), File: rel}) current = len(services) - 1 continue } if current < 0 || indent != 4 { continue } key, value, found := strings.Cut(trimmed, ":") if !found { continue } switch strings.TrimSpace(key) { case "image": services[current].Image = strings.Trim(strings.TrimSpace(value), `"'`) case "ports": if services[current].Ports == "" { services[current].Ports = strings.Trim(strings.TrimSpace(value), `"'`) } else { services[current].Ports += ", " + strings.Trim(strings.TrimSpace(value), `"'`) } } } // Drop the well-known internal networks and volume declarations that the // indentation scanner otherwise mistakes for services. names := map[string]struct{}{} for _, name := range []string{ "networks", "volumes", "configs", "secrets", "services", } { names[name] = struct{}{} } out := services[:0] for _, service := range services { if _, skip := names[service.Name]; skip { continue } out = append(out, service) } return out } // --------------------------------------------------------------------------- // .env files // --------------------------------------------------------------------------- // unquote strips the quoting Go string literals carry around literal values. func unquote(value string) string { if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '`' && value[len(value)-1] == '`') { return value[1 : len(value)-1] } } return value } // envFromExample reads KEY=VALUE pairs out of a .env or .env.example file. func envFromExample(source string) []EnvVar { var out []EnvVar for _, raw := range strings.Split(source, "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, found := strings.Cut(line, "=") if !found { continue } key = strings.TrimSpace(key) if key == "" || !strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { continue } out = append(out, EnvVar{Name: key, Example: strings.Trim(strings.TrimSpace(value), `"'`), FromEnv: true}) } return out } // --------------------------------------------------------------------------- // Misc // --------------------------------------------------------------------------- // portsIn extracts plausible TCP port literals from any file. func portsIn(source string) []string { if source == "" { return nil } var out []string for _, match := range portLiteral.FindAllStringSubmatch(source, -1) { port, err := strconv.Atoi(match[1]) if err != nil || port < 1024 || port > 65535 { continue } out = append(out, match[1]) } return out } // portBearingFiles reports whether a file is a plausible source of a declared // port. Scanning every source file for port literals produces noise (version // numbers, row counts), so only operational files are considered. func portBearingFiles(rel string) bool { base := strings.ToLower(filepath.Base(rel)) switch { case isDockerfile(rel), isComposeFile(rel), isHelmFile(rel): return true case base == "makefile", base == "makefile.am": return true case base == "readme.md": return true case strings.HasPrefix(base, ".env"): return true case strings.HasSuffix(base, ".service"), strings.HasSuffix(base, ".env.example"): return true default: return false } } // isHelmFile reports whether a path is part of a Helm chart, anchored on the two // files that define one. func isHelmFile(rel string) bool { base := strings.ToLower(filepath.Base(rel)) return base == "chart.yaml" || base == "values.yaml" } // markdownTitle returns the first heading of a Markdown document. func markdownTitle(source string) string { for _, raw := range strings.Split(source, "\n") { line := strings.TrimSpace(raw) if strings.HasPrefix(line, "#") { return strings.TrimSpace(strings.TrimLeft(line, "#")) } if line != "" { return "" } } return "" } // findReadme locates the project's README, preferring the repository root. func findReadme(p *Project) string { for _, candidate := range []string{"README.md", "readme.md", "README.markdown", "Readme.md"} { if info, err := os.Stat(filepath.Join(p.Path, candidate)); err == nil && !info.IsDir() { return candidate } } for _, doc := range p.Docs { if strings.EqualFold(filepath.Base(doc.Path), "readme.md") { return doc.Path } } return "" } // summarize produces the one-paragraph preamble shown above the audience tabs. // It prefers the README's opening prose and falls back to a description derived // from what the analyser found, so a project without a README still gets one. func summarize(p *Project) string { if p.Readme != "" { if data, err := readCapped(p.Path+"/"+p.Readme, maxReadBytes); err == nil { if summary := firstParagraph(string(data)); summary != "" { return collapse(summary, 460) } } } parts := []string{} if module := primaryModule(p); module != "" { parts = append(parts, "Go module `"+module+"`") } if p.Stats.GoFiles > 0 { parts = append(parts, plural(p.Stats.GoFiles, "Go source file", "Go source files")) } if len(p.Routes) > 0 { parts = append(parts, plural(len(p.Routes), "HTTP endpoint", "HTTP endpoints")) } if len(p.EnvVars) > 0 { parts = append(parts, plural(len(p.EnvVars), "environment variable", "environment variables")) } if len(parts) == 0 { return "This project has no README yet. The sections below were derived from its structure on disk." } return "This project has no README yet. The analyser found " + joinList(parts) + "." } // firstParagraph returns the opening prose of a document: the first run of // non-empty lines that is not a heading, badge, table or HTML block. func firstParagraph(source string) string { var lines []string for _, raw := range strings.Split(source, "\n") { trimmed := strings.TrimSpace(raw) if strings.HasPrefix(trimmed, "```") { break // prose never starts after a code fence in these READMEs } switch { case trimmed == "": if len(lines) > 0 { return strings.Join(lines, " ") } case strings.HasPrefix(trimmed, "#"): continue case strings.HasPrefix(trimmed, "!"), strings.HasPrefix(trimmed, "[!"), strings.HasPrefix(trimmed, "<"), strings.HasPrefix(trimmed, "|"): continue // badge, HTML or table row default: lines = append(lines, trimmed) } } return strings.Join(lines, " ") } // collapse folds whitespace and truncates on a word boundary. func collapse(text string, limit int) string { fields := strings.Fields(text) if len(fields) == 0 { return "" } out := strings.Join(fields, " ") if len(out) <= limit { return out } cut := out[:limit] if i := strings.LastIndexByte(cut, ' '); i > limit/2 { cut = cut[:i] } return strings.TrimRight(cut, " ,;:.") + "…" } func primaryModule(p *Project) string { for _, module := range p.Modules { if module.Path != "" { return module.Path } } return "" } func plural(n int, singular, many string) string { if n == 1 { return "1 " + singular } return strconv.Itoa(n) + " " + many } func joinList(parts []string) string { switch len(parts) { case 0: return "" case 1: return parts[0] case 2: return parts[0] + " and " + parts[1] default: return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] } }