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  }