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