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  }