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