github.com/phsym/gomarkdoc@v0.5.4/lang/config.go (about)

     1  package lang
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"go/ast"
     7  	"go/doc/comment"
     8  	"go/token"
     9  	"io"
    10  	"os"
    11  	"path/filepath"
    12  	"regexp"
    13  	"strings"
    14  
    15  	"github.com/go-git/go-git/v5"
    16  	"github.com/phsym/gomarkdoc/logger"
    17  	"golang.org/x/mod/modfile"
    18  	"golang.org/x/mod/module"
    19  )
    20  
    21  // GOPRIVATE holds the Value of the environment variable
    22  var GOPRIVATE = os.Getenv("GOPRIVATE")
    23  
    24  type (
    25  	// Config defines contextual information used to resolve documentation for
    26  	// a construct.
    27  	Config struct {
    28  		FileSet    *token.FileSet
    29  		Level      int
    30  		Repo       *Repo
    31  		PkgDir     string
    32  		WorkDir    string
    33  		Log        logger.Logger
    34  		docParser  *comment.Parser
    35  		docPrinter *comment.Printer
    36  		modulePath 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  // getModName reads go.mod in current working directory, and return the full module path
    68  func getModName() (string, error) {
    69  	gomod, err := os.ReadFile("./go.mod")
    70  	if err != nil {
    71  		return "", fmt.Errorf("gomarkdoc: cannot read go.mod from current directory: %w", err)
    72  	}
    73  	return modfile.ModulePath(gomod), nil
    74  }
    75  
    76  // DocLinkURL is a function that computes the URL for the given DocLink.
    77  func (c *Config) DocLinkURL(link *comment.DocLink) string {
    78  	if link.ImportPath == "" {
    79  		// It's a local link (same package)
    80  		return link.DefaultURL("")
    81  	}
    82  
    83  	if c.modulePath != "" && strings.HasPrefix(link.ImportPath, c.modulePath) {
    84  		// If import path belongs to current module (prefixed by module path),
    85  		// TODO: or if path starts with repos hostname + repos path (event if modulePath is empty)
    86  		// then it's a local ref.
    87  		//
    88  		// FIXME: Disable this link for now. We need a way to handle the URL formatting
    89  		// according to the repo provider unless it's a well known repo which may probably be in pkg.go.dev
    90  		return ""
    91  	} else if module.MatchPrefixPatterns(GOPRIVATE, link.ImportPath) {
    92  		// Check for GOPRIVATE variable. If module is private, we can't use pkg.go.dev
    93  		return ""
    94  	}
    95  
    96  	root, _, hasMore := strings.Cut(link.ImportPath, "/")
    97  	// If package is from standard library, or if its root match a hostname (sort of)
    98  	// Use comment.DefaultLookupPackage to check if package is from std library
    99  	if _, isStd := comment.DefaultLookupPackage(root); isStd || hasMore && strings.ContainsRune(root, '.') {
   100  		return link.DefaultURL("https://pkg.go.dev")
   101  	}
   102  	return ""
   103  }
   104  
   105  // NewConfig generates a Config for the provided package directory. It will
   106  // resolve the filepath and attempt to determine the repository containing the
   107  // directory. If no repository is found, the Repo field will be set to nil. An
   108  // error is returned if the provided directory is invalid.
   109  func NewConfig(log logger.Logger, workDir string, pkgDir string, opts ...ConfigOption) (*Config, error) {
   110  	cfg := &Config{
   111  		FileSet:   token.NewFileSet(),
   112  		Level:     1,
   113  		Log:       log,
   114  		docParser: &comment.Parser{},
   115  		docPrinter: &comment.Printer{
   116  			HeadingLevel: 1,
   117  			// Prevent anchors from being added by godoc printer
   118  			HeadingID:      func(h *comment.Heading) string { return "" },
   119  			TextCodePrefix: " ",
   120  		},
   121  	}
   122  
   123  	var err error
   124  
   125  	cfg.modulePath, err = getModName() // This is not optimal,
   126  	if err != nil {
   127  		log.Errorf("module name could not be read: %s", err.Error())
   128  	} else {
   129  		log.Infof("detected module name to be %q", cfg.modulePath)
   130  	}
   131  
   132  	cfg.docPrinter.DocLinkURL = cfg.DocLinkURL
   133  
   134  	for _, opt := range opts {
   135  		if err := opt(cfg); err != nil {
   136  			return nil, err
   137  		}
   138  	}
   139  
   140  	cfg.PkgDir, err = filepath.Abs(pkgDir)
   141  	if err != nil {
   142  		return nil, err
   143  	}
   144  
   145  	cfg.WorkDir, err = filepath.Abs(workDir)
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  
   150  	if cfg.Repo == nil || cfg.Repo.Remote == "" || cfg.Repo.DefaultBranch == "" || cfg.Repo.PathFromRoot == "" {
   151  		repo, err := getRepoForDir(log, cfg.WorkDir, cfg.PkgDir, cfg.Repo)
   152  		if err != nil {
   153  			log.Infof("unable to resolve repository due to error: %s", err)
   154  			cfg.Repo = nil
   155  			return cfg, nil
   156  		}
   157  
   158  		log.Debugf(
   159  			"resolved repository with remote %s, default branch %s, path from root %s",
   160  			repo.Remote,
   161  			repo.DefaultBranch,
   162  			repo.PathFromRoot,
   163  		)
   164  		cfg.Repo = repo
   165  		cfg.docPrinter.DocLinkBaseURL = cfg.Repo.PathFromRoot
   166  	} else {
   167  		log.Debugf("skipping repository resolution because all values have manual overrides")
   168  	}
   169  	return cfg, nil
   170  }
   171  
   172  // Inc copies the Config and increments the level by the provided step.
   173  func (c *Config) Inc(step int) *Config {
   174  	return &Config{
   175  		FileSet:    c.FileSet,
   176  		Level:      c.Level + step,
   177  		PkgDir:     c.PkgDir,
   178  		WorkDir:    c.WorkDir,
   179  		Repo:       c.Repo,
   180  		Log:        c.Log,
   181  		modulePath: c.modulePath,
   182  		docParser:  c.docParser,
   183  		docPrinter: &comment.Printer{
   184  			HeadingLevel:   c.docPrinter.HeadingLevel + step,
   185  			HeadingID:      c.docPrinter.HeadingID,
   186  			DocLinkURL:     c.docPrinter.DocLinkURL,
   187  			DocLinkBaseURL: c.docPrinter.DocLinkBaseURL,
   188  			TextPrefix:     c.docPrinter.TextPrefix,
   189  			TextCodePrefix: c.docPrinter.TextCodePrefix,
   190  			TextWidth:      c.docPrinter.TextWidth,
   191  		},
   192  	}
   193  }
   194  
   195  // ConfigWithRepoOverrides defines a set of manual overrides for the repository
   196  // information to be used in place of automatic repository detection.
   197  func ConfigWithRepoOverrides(overrides *Repo) ConfigOption {
   198  	return func(c *Config) error {
   199  		if overrides == nil {
   200  			return nil
   201  		}
   202  
   203  		if overrides.PathFromRoot != "" {
   204  			// Convert it to the right pathing system
   205  			unslashed := filepath.FromSlash(overrides.PathFromRoot)
   206  
   207  			if len(unslashed) == 0 || unslashed[0] != filepath.Separator {
   208  				return fmt.Errorf("provided repository path %s must be absolute", overrides.PathFromRoot)
   209  			}
   210  
   211  			overrides.PathFromRoot = unslashed
   212  		}
   213  
   214  		c.Repo = overrides
   215  		c.docPrinter.DocLinkBaseURL = c.Repo.PathFromRoot
   216  		return nil
   217  	}
   218  }
   219  
   220  func getRepoForDir(log logger.Logger, wd string, dir string, ri *Repo) (*Repo, error) {
   221  	if ri == nil {
   222  		ri = &Repo{}
   223  	}
   224  
   225  	repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{
   226  		DetectDotGit: true,
   227  	})
   228  	if err != nil {
   229  		return nil, err
   230  	}
   231  
   232  	// Set the path from root if there wasn't one
   233  	if ri.PathFromRoot == "" {
   234  		t, err := repo.Worktree()
   235  		if err != nil {
   236  			return nil, err
   237  		}
   238  
   239  		// Get the path from the root of the repo to the working dir, then make
   240  		// it absolute (i.e. prefix with /).
   241  		p, err := filepath.Rel(t.Filesystem.Root(), wd)
   242  		if err != nil {
   243  			return nil, err
   244  		}
   245  
   246  		ri.PathFromRoot = filepath.Join(string(filepath.Separator), p)
   247  	}
   248  
   249  	// No need to check remotes if we already have a url and a default branch
   250  	if ri.Remote != "" && ri.DefaultBranch != "" {
   251  		return ri, nil
   252  	}
   253  
   254  	remotes, err := repo.Remotes()
   255  	if err != nil {
   256  		return nil, err
   257  	}
   258  
   259  	for _, r := range remotes {
   260  		if repo, ok := processRemote(log, repo, r, *ri); ok {
   261  			ri = repo
   262  			break
   263  		}
   264  	}
   265  
   266  	// If there's no "origin", just use the first remote
   267  	if ri.DefaultBranch == "" || ri.Remote == "" {
   268  		if len(remotes) == 0 {
   269  			return nil, errors.New("no remotes found for repository")
   270  		}
   271  
   272  		repo, ok := processRemote(log, repo, remotes[0], *ri)
   273  		if !ok {
   274  			return nil, errors.New("no remotes found for repository")
   275  		}
   276  
   277  		ri = repo
   278  	}
   279  
   280  	return ri, nil
   281  }
   282  
   283  func processRemote(log logger.Logger, repository *git.Repository, remote *git.Remote, ri Repo) (*Repo, bool) {
   284  	repo := &ri
   285  
   286  	c := remote.Config()
   287  
   288  	// TODO: configurable remote name?
   289  	if c.Name != "origin" || len(c.URLs) == 0 {
   290  		log.Debugf("skipping remote because it is not the origin or it has no URLs")
   291  		return nil, false
   292  	}
   293  
   294  	// Only detect the default branch if we don't already have one
   295  	if repo.DefaultBranch == "" {
   296  		refs, err := repository.References()
   297  		if err != nil {
   298  			log.Debugf("skipping remote %s because listing its refs failed: %s", c.URLs[0], err)
   299  			return nil, false
   300  		}
   301  
   302  		prefix := fmt.Sprintf("refs/remotes/%s/", c.Name)
   303  		headRef := fmt.Sprintf("refs/remotes/%s/HEAD", c.Name)
   304  
   305  		for {
   306  			ref, err := refs.Next()
   307  			if err != nil {
   308  				if err == io.EOF {
   309  					break
   310  				}
   311  
   312  				log.Debugf("skipping remote %s because listing its refs failed: %s", c.URLs[0], err)
   313  				return nil, false
   314  			}
   315  			defer refs.Close()
   316  
   317  			if ref == nil {
   318  				break
   319  			}
   320  
   321  			if string(ref.Name()) == headRef && strings.HasPrefix(string(ref.Target()), prefix) {
   322  				repo.DefaultBranch = strings.TrimPrefix(string(ref.Target()), prefix)
   323  				log.Debugf("found default branch %s for remote %s", repo.DefaultBranch, c.URLs[0])
   324  				break
   325  			}
   326  		}
   327  
   328  		if repo.DefaultBranch == "" {
   329  			log.Debugf("skipping remote %s because no default branch was found", c.URLs[0])
   330  			return nil, false
   331  		}
   332  	}
   333  
   334  	// If we already have the remote from an override, we don't need to detect.
   335  	if repo.Remote != "" {
   336  		return repo, true
   337  	}
   338  
   339  	normalized, ok := normalizeRemote(c.URLs[0])
   340  	if !ok {
   341  		log.Debugf("skipping remote %s because its remote URL could not be normalized", c.URLs[0])
   342  		return nil, false
   343  	}
   344  
   345  	repo.Remote = normalized
   346  	return repo, true
   347  }
   348  
   349  var (
   350  	sshRemoteRegex       = regexp.MustCompile(`^[\w-]+@([^:]+):(.+?)(?:\.git)?$`)
   351  	httpsRemoteRegex     = regexp.MustCompile(`^(https?://)(?:[^@/]+@)?([\w-.]+)(/.+?)?(?:\.git)?$`)
   352  	devOpsSSHV3PathRegex = regexp.MustCompile(`^v3/([^/]+)/([^/]+)/([^/]+)$`)
   353  	devOpsHTTPSPathRegex = regexp.MustCompile(`^/([^/]+)/([^/]+)/_git/([^/]+)$`)
   354  )
   355  
   356  func normalizeRemote(remote string) (string, bool) {
   357  	if match := sshRemoteRegex.FindStringSubmatch(remote); match != nil {
   358  		switch match[1] {
   359  		case "ssh.dev.azure.com", "vs-ssh.visualstudio.com":
   360  			if pathMatch := devOpsSSHV3PathRegex.FindStringSubmatch(match[2]); pathMatch != nil {
   361  				// DevOps v3
   362  				return fmt.Sprintf(
   363  					"https://dev.azure.com/%s/%s/_git/%s",
   364  					pathMatch[1],
   365  					pathMatch[2],
   366  					pathMatch[3],
   367  				), true
   368  			}
   369  
   370  			return "", false
   371  		default:
   372  			// GitHub and friends
   373  			return fmt.Sprintf("https://%s/%s", match[1], match[2]), true
   374  		}
   375  	}
   376  
   377  	if match := httpsRemoteRegex.FindStringSubmatch(remote); match != nil {
   378  		switch {
   379  		case match[2] == "dev.azure.com":
   380  			if pathMatch := devOpsHTTPSPathRegex.FindStringSubmatch(match[3]); pathMatch != nil {
   381  				// DevOps
   382  				return fmt.Sprintf(
   383  					"https://dev.azure.com/%s/%s/_git/%s",
   384  					pathMatch[1],
   385  					pathMatch[2],
   386  					pathMatch[3],
   387  				), true
   388  			}
   389  
   390  			return "", false
   391  		case strings.HasSuffix(match[2], ".visualstudio.com"):
   392  			if pathMatch := devOpsHTTPSPathRegex.FindStringSubmatch(match[3]); pathMatch != nil {
   393  				// DevOps (old domain)
   394  
   395  				// Pull off the beginning of the domain
   396  				org := strings.SplitN(match[2], ".", 2)[0]
   397  				return fmt.Sprintf(
   398  					"https://dev.azure.com/%s/%s/_git/%s",
   399  					org,
   400  					pathMatch[2],
   401  					pathMatch[3],
   402  				), true
   403  			}
   404  
   405  			return "", false
   406  		default:
   407  			// GitHub and friends
   408  			return fmt.Sprintf("%s%s%s", match[1], match[2], match[3]), true
   409  		}
   410  	}
   411  
   412  	// TODO: error instead?
   413  	return "", false
   414  }
   415  
   416  // NewLocation returns a location for the provided Config and ast.Node
   417  // combination. This is typically not called directly, but is made available via
   418  // the Location() methods of various lang constructs.
   419  func NewLocation(cfg *Config, node ast.Node) Location {
   420  	start := cfg.FileSet.Position(node.Pos())
   421  	end := cfg.FileSet.Position(node.End())
   422  
   423  	return Location{
   424  		Start:    Position{start.Line, start.Column},
   425  		End:      Position{end.Line, end.Column},
   426  		Filepath: start.Filename,
   427  		WorkDir:  cfg.WorkDir,
   428  		Repo:     cfg.Repo,
   429  	}
   430  }