github.com/phsym/gomarkdoc@v0.5.4/lang/config.go (about) 1 package lang 2 3 import ( 4 "errors" 5 "fmt" 6 "go/ast" 7 "go/doc/comment" 8 "go/token" 9 "io" 10 "os" 11 "path/filepath" 12 "regexp" 13 "strings" 14 15 "github.com/go-git/go-git/v5" 16 "github.com/phsym/gomarkdoc/logger" 17 "golang.org/x/mod/modfile" 18 "golang.org/x/mod/module" 19 ) 20 21 // GOPRIVATE holds the Value of the environment variable 22 var GOPRIVATE = os.Getenv("GOPRIVATE") 23 24 type ( 25 // Config defines contextual information used to resolve documentation for 26 // a construct. 27 Config struct { 28 FileSet *token.FileSet 29 Level int 30 Repo *Repo 31 PkgDir string 32 WorkDir string 33 Log logger.Logger 34 docParser *comment.Parser 35 docPrinter *comment.Printer 36 modulePath string 37 } 38 39 // Repo represents information about a repository relevant to documentation 40 // generation. 41 Repo struct { 42 Remote string 43 DefaultBranch string 44 PathFromRoot string 45 } 46 47 // Location holds information for identifying a position within a file and 48 // repository, if present. 49 Location struct { 50 Start Position 51 End Position 52 Filepath string 53 WorkDir string 54 Repo *Repo 55 } 56 57 // Position represents a line and column number within a file. 58 Position struct { 59 Line int 60 Col int 61 } 62 63 // ConfigOption modifies the Config generated by NewConfig. 64 ConfigOption func(c *Config) error 65 ) 66 67 // getModName reads go.mod in current working directory, and return the full module path 68 func getModName() (string, error) { 69 gomod, err := os.ReadFile("./go.mod") 70 if err != nil { 71 return "", fmt.Errorf("gomarkdoc: cannot read go.mod from current directory: %w", err) 72 } 73 return modfile.ModulePath(gomod), nil 74 } 75 76 // DocLinkURL is a function that computes the URL for the given DocLink. 77 func (c *Config) DocLinkURL(link *comment.DocLink) string { 78 if link.ImportPath == "" { 79 // It's a local link (same package) 80 return link.DefaultURL("") 81 } 82 83 if c.modulePath != "" && strings.HasPrefix(link.ImportPath, c.modulePath) { 84 // If import path belongs to current module (prefixed by module path), 85 // TODO: or if path starts with repos hostname + repos path (event if modulePath is empty) 86 // then it's a local ref. 87 // 88 // FIXME: Disable this link for now. We need a way to handle the URL formatting 89 // according to the repo provider unless it's a well known repo which may probably be in pkg.go.dev 90 return "" 91 } else if module.MatchPrefixPatterns(GOPRIVATE, link.ImportPath) { 92 // Check for GOPRIVATE variable. If module is private, we can't use pkg.go.dev 93 return "" 94 } 95 96 root, _, hasMore := strings.Cut(link.ImportPath, "/") 97 // If package is from standard library, or if its root match a hostname (sort of) 98 // Use comment.DefaultLookupPackage to check if package is from std library 99 if _, isStd := comment.DefaultLookupPackage(root); isStd || hasMore && strings.ContainsRune(root, '.') { 100 return link.DefaultURL("https://pkg.go.dev") 101 } 102 return "" 103 } 104 105 // NewConfig generates a Config for the provided package directory. It will 106 // resolve the filepath and attempt to determine the repository containing the 107 // directory. If no repository is found, the Repo field will be set to nil. An 108 // error is returned if the provided directory is invalid. 109 func NewConfig(log logger.Logger, workDir string, pkgDir string, opts ...ConfigOption) (*Config, error) { 110 cfg := &Config{ 111 FileSet: token.NewFileSet(), 112 Level: 1, 113 Log: log, 114 docParser: &comment.Parser{}, 115 docPrinter: &comment.Printer{ 116 HeadingLevel: 1, 117 // Prevent anchors from being added by godoc printer 118 HeadingID: func(h *comment.Heading) string { return "" }, 119 TextCodePrefix: " ", 120 }, 121 } 122 123 var err error 124 125 cfg.modulePath, err = getModName() // This is not optimal, 126 if err != nil { 127 log.Errorf("module name could not be read: %s", err.Error()) 128 } else { 129 log.Infof("detected module name to be %q", cfg.modulePath) 130 } 131 132 cfg.docPrinter.DocLinkURL = cfg.DocLinkURL 133 134 for _, opt := range opts { 135 if err := opt(cfg); err != nil { 136 return nil, err 137 } 138 } 139 140 cfg.PkgDir, err = filepath.Abs(pkgDir) 141 if err != nil { 142 return nil, err 143 } 144 145 cfg.WorkDir, err = filepath.Abs(workDir) 146 if err != nil { 147 return nil, err 148 } 149 150 if cfg.Repo == nil || cfg.Repo.Remote == "" || cfg.Repo.DefaultBranch == "" || cfg.Repo.PathFromRoot == "" { 151 repo, err := getRepoForDir(log, cfg.WorkDir, cfg.PkgDir, cfg.Repo) 152 if err != nil { 153 log.Infof("unable to resolve repository due to error: %s", err) 154 cfg.Repo = nil 155 return cfg, nil 156 } 157 158 log.Debugf( 159 "resolved repository with remote %s, default branch %s, path from root %s", 160 repo.Remote, 161 repo.DefaultBranch, 162 repo.PathFromRoot, 163 ) 164 cfg.Repo = repo 165 cfg.docPrinter.DocLinkBaseURL = cfg.Repo.PathFromRoot 166 } else { 167 log.Debugf("skipping repository resolution because all values have manual overrides") 168 } 169 return cfg, nil 170 } 171 172 // Inc copies the Config and increments the level by the provided step. 173 func (c *Config) Inc(step int) *Config { 174 return &Config{ 175 FileSet: c.FileSet, 176 Level: c.Level + step, 177 PkgDir: c.PkgDir, 178 WorkDir: c.WorkDir, 179 Repo: c.Repo, 180 Log: c.Log, 181 modulePath: c.modulePath, 182 docParser: c.docParser, 183 docPrinter: &comment.Printer{ 184 HeadingLevel: c.docPrinter.HeadingLevel + step, 185 HeadingID: c.docPrinter.HeadingID, 186 DocLinkURL: c.docPrinter.DocLinkURL, 187 DocLinkBaseURL: c.docPrinter.DocLinkBaseURL, 188 TextPrefix: c.docPrinter.TextPrefix, 189 TextCodePrefix: c.docPrinter.TextCodePrefix, 190 TextWidth: c.docPrinter.TextWidth, 191 }, 192 } 193 } 194 195 // ConfigWithRepoOverrides defines a set of manual overrides for the repository 196 // information to be used in place of automatic repository detection. 197 func ConfigWithRepoOverrides(overrides *Repo) ConfigOption { 198 return func(c *Config) error { 199 if overrides == nil { 200 return nil 201 } 202 203 if overrides.PathFromRoot != "" { 204 // Convert it to the right pathing system 205 unslashed := filepath.FromSlash(overrides.PathFromRoot) 206 207 if len(unslashed) == 0 || unslashed[0] != filepath.Separator { 208 return fmt.Errorf("provided repository path %s must be absolute", overrides.PathFromRoot) 209 } 210 211 overrides.PathFromRoot = unslashed 212 } 213 214 c.Repo = overrides 215 c.docPrinter.DocLinkBaseURL = c.Repo.PathFromRoot 216 return nil 217 } 218 } 219 220 func getRepoForDir(log logger.Logger, wd string, dir string, ri *Repo) (*Repo, error) { 221 if ri == nil { 222 ri = &Repo{} 223 } 224 225 repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{ 226 DetectDotGit: true, 227 }) 228 if err != nil { 229 return nil, err 230 } 231 232 // Set the path from root if there wasn't one 233 if ri.PathFromRoot == "" { 234 t, err := repo.Worktree() 235 if err != nil { 236 return nil, err 237 } 238 239 // Get the path from the root of the repo to the working dir, then make 240 // it absolute (i.e. prefix with /). 241 p, err := filepath.Rel(t.Filesystem.Root(), wd) 242 if err != nil { 243 return nil, err 244 } 245 246 ri.PathFromRoot = filepath.Join(string(filepath.Separator), p) 247 } 248 249 // No need to check remotes if we already have a url and a default branch 250 if ri.Remote != "" && ri.DefaultBranch != "" { 251 return ri, nil 252 } 253 254 remotes, err := repo.Remotes() 255 if err != nil { 256 return nil, err 257 } 258 259 for _, r := range remotes { 260 if repo, ok := processRemote(log, repo, r, *ri); ok { 261 ri = repo 262 break 263 } 264 } 265 266 // If there's no "origin", just use the first remote 267 if ri.DefaultBranch == "" || ri.Remote == "" { 268 if len(remotes) == 0 { 269 return nil, errors.New("no remotes found for repository") 270 } 271 272 repo, ok := processRemote(log, repo, remotes[0], *ri) 273 if !ok { 274 return nil, errors.New("no remotes found for repository") 275 } 276 277 ri = repo 278 } 279 280 return ri, nil 281 } 282 283 func processRemote(log logger.Logger, repository *git.Repository, remote *git.Remote, ri Repo) (*Repo, bool) { 284 repo := &ri 285 286 c := remote.Config() 287 288 // TODO: configurable remote name? 289 if c.Name != "origin" || len(c.URLs) == 0 { 290 log.Debugf("skipping remote because it is not the origin or it has no URLs") 291 return nil, false 292 } 293 294 // Only detect the default branch if we don't already have one 295 if repo.DefaultBranch == "" { 296 refs, err := repository.References() 297 if err != nil { 298 log.Debugf("skipping remote %s because listing its refs failed: %s", c.URLs[0], err) 299 return nil, false 300 } 301 302 prefix := fmt.Sprintf("refs/remotes/%s/", c.Name) 303 headRef := fmt.Sprintf("refs/remotes/%s/HEAD", c.Name) 304 305 for { 306 ref, err := refs.Next() 307 if err != nil { 308 if err == io.EOF { 309 break 310 } 311 312 log.Debugf("skipping remote %s because listing its refs failed: %s", c.URLs[0], err) 313 return nil, false 314 } 315 defer refs.Close() 316 317 if ref == nil { 318 break 319 } 320 321 if string(ref.Name()) == headRef && strings.HasPrefix(string(ref.Target()), prefix) { 322 repo.DefaultBranch = strings.TrimPrefix(string(ref.Target()), prefix) 323 log.Debugf("found default branch %s for remote %s", repo.DefaultBranch, c.URLs[0]) 324 break 325 } 326 } 327 328 if repo.DefaultBranch == "" { 329 log.Debugf("skipping remote %s because no default branch was found", c.URLs[0]) 330 return nil, false 331 } 332 } 333 334 // If we already have the remote from an override, we don't need to detect. 335 if repo.Remote != "" { 336 return repo, true 337 } 338 339 normalized, ok := normalizeRemote(c.URLs[0]) 340 if !ok { 341 log.Debugf("skipping remote %s because its remote URL could not be normalized", c.URLs[0]) 342 return nil, false 343 } 344 345 repo.Remote = normalized 346 return repo, true 347 } 348 349 var ( 350 sshRemoteRegex = regexp.MustCompile(`^[\w-]+@([^:]+):(.+?)(?:\.git)?$`) 351 httpsRemoteRegex = regexp.MustCompile(`^(https?://)(?:[^@/]+@)?([\w-.]+)(/.+?)?(?:\.git)?$`) 352 devOpsSSHV3PathRegex = regexp.MustCompile(`^v3/([^/]+)/([^/]+)/([^/]+)$`) 353 devOpsHTTPSPathRegex = regexp.MustCompile(`^/([^/]+)/([^/]+)/_git/([^/]+)$`) 354 ) 355 356 func normalizeRemote(remote string) (string, bool) { 357 if match := sshRemoteRegex.FindStringSubmatch(remote); match != nil { 358 switch match[1] { 359 case "ssh.dev.azure.com", "vs-ssh.visualstudio.com": 360 if pathMatch := devOpsSSHV3PathRegex.FindStringSubmatch(match[2]); pathMatch != nil { 361 // DevOps v3 362 return fmt.Sprintf( 363 "https://dev.azure.com/%s/%s/_git/%s", 364 pathMatch[1], 365 pathMatch[2], 366 pathMatch[3], 367 ), true 368 } 369 370 return "", false 371 default: 372 // GitHub and friends 373 return fmt.Sprintf("https://%s/%s", match[1], match[2]), true 374 } 375 } 376 377 if match := httpsRemoteRegex.FindStringSubmatch(remote); match != nil { 378 switch { 379 case match[2] == "dev.azure.com": 380 if pathMatch := devOpsHTTPSPathRegex.FindStringSubmatch(match[3]); pathMatch != nil { 381 // DevOps 382 return fmt.Sprintf( 383 "https://dev.azure.com/%s/%s/_git/%s", 384 pathMatch[1], 385 pathMatch[2], 386 pathMatch[3], 387 ), true 388 } 389 390 return "", false 391 case strings.HasSuffix(match[2], ".visualstudio.com"): 392 if pathMatch := devOpsHTTPSPathRegex.FindStringSubmatch(match[3]); pathMatch != nil { 393 // DevOps (old domain) 394 395 // Pull off the beginning of the domain 396 org := strings.SplitN(match[2], ".", 2)[0] 397 return fmt.Sprintf( 398 "https://dev.azure.com/%s/%s/_git/%s", 399 org, 400 pathMatch[2], 401 pathMatch[3], 402 ), true 403 } 404 405 return "", false 406 default: 407 // GitHub and friends 408 return fmt.Sprintf("%s%s%s", match[1], match[2], match[3]), true 409 } 410 } 411 412 // TODO: error instead? 413 return "", false 414 } 415 416 // NewLocation returns a location for the provided Config and ast.Node 417 // combination. This is typically not called directly, but is made available via 418 // the Location() methods of various lang constructs. 419 func NewLocation(cfg *Config, node ast.Node) Location { 420 start := cfg.FileSet.Position(node.Pos()) 421 end := cfg.FileSet.Position(node.End()) 422 423 return Location{ 424 Start: Position{start.Line, start.Column}, 425 End: Position{end.Line, end.Column}, 426 Filepath: start.Filename, 427 WorkDir: cfg.WorkDir, 428 Repo: cfg.Repo, 429 } 430 }