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