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  }