pkg.re/essentialkaos/ek.10@v12.41.0+incompatible/usage/usage.go (about)

     1  // Package usage provides methods and structs for generating usage info for
     2  // command-line tools
     3  package usage
     4  
     5  // ////////////////////////////////////////////////////////////////////////////////// //
     6  //                                                                                    //
     7  //                         Copyright (c) 2022 ESSENTIAL KAOS                          //
     8  //      Apache License, Version 2.0 <https://www.apache.org/licenses/LICENSE-2.0>     //
     9  //                                                                                    //
    10  // ////////////////////////////////////////////////////////////////////////////////// //
    11  
    12  import (
    13  	"os"
    14  	"path/filepath"
    15  	"strings"
    16  	"time"
    17  
    18  	"pkg.re/essentialkaos/ek.v12/fmtc"
    19  	"pkg.re/essentialkaos/ek.v12/mathutil"
    20  	"pkg.re/essentialkaos/ek.v12/strutil"
    21  	"pkg.re/essentialkaos/ek.v12/version"
    22  )
    23  
    24  // ////////////////////////////////////////////////////////////////////////////////// //
    25  
    26  const (
    27  	_SPACES = "                                                                "
    28  	_DOTS   = "................................................................"
    29  )
    30  
    31  const _BREADCRUMBS_MIN_SIZE = 8
    32  
    33  // ////////////////////////////////////////////////////////////////////////////////// //
    34  
    35  const (
    36  	DEFAULT_COMMANDS_COLOR_TAG = "{y}"
    37  	DEFAULT_OPTIONS_COLOR_TAG  = "{g}"
    38  	DEFAULT_APP_NAME_COLOR_TAG = "{c*}"
    39  	DEFAULT_APP_VER_COLOR_TAG  = "{c}"
    40  )
    41  
    42  // ////////////////////////////////////////////////////////////////////////////////// //
    43  
    44  // About contains info about application
    45  type About struct {
    46  	App        string // App is application name
    47  	Version    string // Version is current application version in semver notation
    48  	Release    string // Release is current application release
    49  	Build      string // Build is current application build
    50  	Desc       string // Desc is short info about application
    51  	Year       int    // Year is year when owner company was founded
    52  	License    string // License is name of license
    53  	Owner      string // Owner is name of owner (company/developer)
    54  	BugTracker string // BugTracker is URL of bug tracker
    55  
    56  	AppNameColorTag string // AppNameColorTag contains default app name color tag
    57  	VersionColorTag string // VersionColorTag contains default app version color tag
    58  
    59  	// Function for checking application updates
    60  	UpdateChecker UpdateChecker
    61  }
    62  
    63  // Info contains info about commands, options, and examples
    64  type Info struct {
    65  	AppNameColorTag  string // AppNameColorTag contains default app name color tag
    66  	CommandsColorTag string // CommandsColorTag contains default commands color tag
    67  	OptionsColorTag  string // OptionsColorTag contains default options color tag
    68  	Breadcrumbs      bool   // Breadcrumbs is flag for using bread crumbs for commands and options output
    69  
    70  	Name    string   // Name is app name
    71  	Args    []string // Args is slice with app arguments
    72  	Spoiler string   // Spoiler contains additional info
    73  
    74  	Commands []*Command // Commands is list of supported commands
    75  	Options  []*Option  // Options is list of supported options
    76  	Examples []*Example // Examples is list of usage examples
    77  
    78  	curGroup string
    79  }
    80  
    81  // UpdateChecker is a base for all update checkers
    82  type UpdateChecker struct {
    83  	Payload   string
    84  	CheckFunc func(app, version, data string) (string, time.Time, bool)
    85  }
    86  
    87  // ////////////////////////////////////////////////////////////////////////////////// //
    88  
    89  // Command contains info about supported command
    90  type Command struct {
    91  	Name         string   // Name is command name
    92  	Desc         string   // Desc is command description
    93  	Group        string   // Group is group name
    94  	Args         []string // Args is slice with arguments
    95  	BoundOptions []string // BoundOptions is slice with long names of related options
    96  
    97  	ColorTag string // ColorTag contains default color tag
    98  
    99  	info *Info
   100  }
   101  
   102  // Option contains info about supported option
   103  type Option struct {
   104  	Short string // Short is short option name (with one minus prefix)
   105  	Long  string // Long is long option name (with two minuses prefix)
   106  	Desc  string // Desc is option description
   107  	Arg   string // Arg is option argument
   108  
   109  	ColorTag string // ColorTag contains default color tag
   110  
   111  	info *Info
   112  }
   113  
   114  // Example contains usage example
   115  type Example struct {
   116  	Cmd  string // Cmd is command usage example
   117  	Desc string // Desc is usage description
   118  	Raw  bool   // Raw is raw example flag (without automatic binary name appending)
   119  
   120  	info *Info
   121  }
   122  
   123  // ////////////////////////////////////////////////////////////////////////////////// //
   124  
   125  // NewInfo creates new info struct
   126  func NewInfo(args ...string) *Info {
   127  	var name string
   128  
   129  	if len(args) != 0 {
   130  		name = args[0]
   131  		args = args[1:]
   132  	}
   133  
   134  	name = strutil.Q(name, filepath.Base(os.Args[0]))
   135  
   136  	info := &Info{
   137  		Name: name,
   138  		Args: args,
   139  
   140  		CommandsColorTag: DEFAULT_COMMANDS_COLOR_TAG,
   141  		OptionsColorTag:  DEFAULT_OPTIONS_COLOR_TAG,
   142  		Breadcrumbs:      true,
   143  	}
   144  
   145  	return info
   146  }
   147  
   148  // AddGroup adds new command group
   149  func (i *Info) AddGroup(group string) {
   150  	i.curGroup = group
   151  }
   152  
   153  // AddCommand adds command (name, description, args)
   154  func (i *Info) AddCommand(a ...string) {
   155  	group := "Commands"
   156  
   157  	if i.curGroup != "" {
   158  		group = i.curGroup
   159  	}
   160  
   161  	if len(a) < 2 {
   162  		return
   163  	}
   164  
   165  	i.Commands = append(
   166  		i.Commands,
   167  		&Command{
   168  			Name:  a[0],
   169  			Desc:  a[1],
   170  			Args:  a[2:],
   171  			Group: group,
   172  			info:  i,
   173  		},
   174  	)
   175  }
   176  
   177  // AddOption adds option (name, description, args)
   178  func (i *Info) AddOption(a ...string) {
   179  	if len(a) < 2 {
   180  		return
   181  	}
   182  
   183  	long, short := parseOptionName(a[0])
   184  
   185  	i.Options = append(
   186  		i.Options,
   187  		&Option{
   188  			Long:  long,
   189  			Short: short,
   190  			Desc:  a[1],
   191  			Arg:   strings.Join(a[2:], " "),
   192  			info:  i,
   193  		},
   194  	)
   195  }
   196  
   197  // AddExample adds example of application usage
   198  func (i *Info) AddExample(a ...string) {
   199  	if len(a) == 0 {
   200  		return
   201  	}
   202  
   203  	a = append(a, "")
   204  
   205  	i.Examples = append(i.Examples, &Example{a[0], a[1], false, i})
   206  }
   207  
   208  // AddRawExample adds example of application usage without command prefix
   209  func (i *Info) AddRawExample(a ...string) {
   210  	if len(a) == 0 {
   211  		return
   212  	}
   213  
   214  	a = append(a, "")
   215  
   216  	i.Examples = append(i.Examples, &Example{a[0], a[1], true, i})
   217  }
   218  
   219  // AddSpoiler adds spoiler
   220  func (i *Info) AddSpoiler(spoiler string) {
   221  	i.Spoiler = spoiler
   222  }
   223  
   224  // BoundOptions bounds command with options
   225  func (i *Info) BoundOptions(cmd string, options ...string) {
   226  	for _, command := range i.Commands {
   227  		if command.Name == cmd {
   228  			for _, opt := range options {
   229  				longOption, _ := parseOptionName(opt)
   230  				command.BoundOptions = append(command.BoundOptions, longOption)
   231  			}
   232  
   233  			return
   234  		}
   235  	}
   236  }
   237  
   238  // GetCommand tries to find command with given name
   239  func (i *Info) GetCommand(name string) *Command {
   240  	for _, command := range i.Commands {
   241  		if command.Name == name {
   242  			return command
   243  		}
   244  	}
   245  
   246  	return nil
   247  }
   248  
   249  // GetOption tries to find option with given name
   250  func (i *Info) GetOption(name string) *Option {
   251  	name, _ = parseOptionName(name)
   252  
   253  	for _, option := range i.Options {
   254  		if option.Long == name {
   255  			return option
   256  		}
   257  	}
   258  
   259  	return nil
   260  }
   261  
   262  // Render prints usage info to console
   263  func (i *Info) Render() {
   264  	usageMessage := "\n{*}Usage:{!} " + i.AppNameColorTag + i.Name + "{!}"
   265  
   266  	if len(i.Options) != 0 {
   267  		usageMessage += " " + i.OptionsColorTag + "{options}{!}"
   268  	}
   269  
   270  	if len(i.Commands) != 0 {
   271  		usageMessage += " " + i.CommandsColorTag + "{command}{!}"
   272  	}
   273  
   274  	if len(i.Args) != 0 {
   275  		usageMessage += " " + strings.Join(i.Args, " ")
   276  	}
   277  
   278  	fmtc.Println(usageMessage)
   279  
   280  	if i.Spoiler != "" {
   281  		fmtc.NewLine()
   282  		fmtc.Println(i.Spoiler)
   283  	}
   284  
   285  	if len(i.Commands) != 0 {
   286  		renderCommands(i)
   287  	}
   288  
   289  	if len(i.Options) != 0 {
   290  		renderOptions(i)
   291  	}
   292  
   293  	if len(i.Examples) != 0 {
   294  		renderExamples(i)
   295  	}
   296  
   297  	fmtc.NewLine()
   298  }
   299  
   300  // ////////////////////////////////////////////////////////////////////////////////// //
   301  
   302  // String returns a string representation of the command
   303  func (c *Command) String() string {
   304  	if c == nil {
   305  		return ""
   306  	}
   307  
   308  	return c.Name
   309  }
   310  
   311  // String returns a string representation of the option
   312  func (o *Option) String() string {
   313  	if o == nil {
   314  		return ""
   315  	}
   316  
   317  	return "--" + o.Long
   318  }
   319  
   320  // ////////////////////////////////////////////////////////////////////////////////// //
   321  
   322  // Render renders info about command
   323  func (c *Command) Render() {
   324  	colorTag := strutil.Q(DEFAULT_COMMANDS_COLOR_TAG, c.ColorTag)
   325  	size := getCommandSize(c)
   326  	useBreadcrumbs := true
   327  	maxSize := size
   328  
   329  	if c.info != nil {
   330  		colorTag = c.info.CommandsColorTag
   331  		maxSize = getMaxCommandSize(c.info.Commands)
   332  		useBreadcrumbs = c.info.Breadcrumbs
   333  	}
   334  
   335  	fmtc.Printf("  "+colorTag+"%s{!}", c.Name)
   336  
   337  	if len(c.Args) != 0 {
   338  		fmtc.Printf(" " + renderArgs(c.Args...))
   339  	}
   340  
   341  	fmtc.Printf(getSeparator(size, maxSize, useBreadcrumbs))
   342  	fmtc.Printf(c.Desc)
   343  
   344  	fmtc.NewLine()
   345  }
   346  
   347  // Render renders info about option
   348  func (o *Option) Render() {
   349  	colorTag := strutil.Q(DEFAULT_OPTIONS_COLOR_TAG, o.ColorTag)
   350  	size := getOptionSize(o)
   351  	useBreadcrumbs := true
   352  	maxSize := size
   353  
   354  	if o.info != nil {
   355  		colorTag = o.info.OptionsColorTag
   356  		maxSize = getMaxOptionSize(o.info.Options)
   357  		useBreadcrumbs = o.info.Breadcrumbs
   358  	}
   359  
   360  	fmtc.Printf("  "+colorTag+"%s{!}", formatOptionName(o))
   361  
   362  	if o.Arg != "" {
   363  		fmtc.Printf(" " + renderArgs(o.Arg))
   364  	}
   365  
   366  	fmtc.Printf(getSeparator(size, maxSize, useBreadcrumbs))
   367  	fmtc.Printf(o.Desc)
   368  
   369  	fmtc.NewLine()
   370  }
   371  
   372  // Render renders usage example
   373  func (e *Example) Render() {
   374  	appName := os.Args[0]
   375  
   376  	if e.info != nil {
   377  		appName = e.info.Name
   378  	}
   379  
   380  	if e.Raw {
   381  		fmtc.Printf("  %s\n", e.Cmd)
   382  	} else {
   383  		fmtc.Printf("  %s %s\n", appName, e.Cmd)
   384  	}
   385  
   386  	if e.Desc != "" {
   387  		fmtc.Printf("  {s-}%s{!}\n", e.Desc)
   388  	}
   389  }
   390  
   391  // ////////////////////////////////////////////////////////////////////////////////// //
   392  
   393  // Render prints version info to console
   394  func (a *About) Render() {
   395  	nc := strutil.Q(a.AppNameColorTag, DEFAULT_APP_NAME_COLOR_TAG)
   396  	vc := strutil.Q(a.VersionColorTag, DEFAULT_APP_VER_COLOR_TAG)
   397  
   398  	switch {
   399  	case a.Build != "":
   400  		fmtc.Printf(
   401  			"\n"+nc+"%s{!} "+vc+"%s{!}{s}%s{!} {s-}(%s){!} - %s\n\n",
   402  			a.App, a.Version,
   403  			a.Release, a.Build, a.Desc,
   404  		)
   405  	default:
   406  		fmtc.Printf(
   407  			"\n"+nc+"%s{!} "+vc+"%s{!}{s}%s{!} - %s\n\n",
   408  			a.App, a.Version,
   409  			a.Release, a.Desc,
   410  		)
   411  	}
   412  
   413  	if a.Owner != "" {
   414  		if a.Year == 0 {
   415  			fmtc.Printf(
   416  				"{s-}Copyright (C) %d %s{!}\n",
   417  				time.Now().Year(), a.Owner,
   418  			)
   419  		} else {
   420  			fmtc.Printf(
   421  				"{s-}Copyright (C) %d-%d %s{!}\n",
   422  				a.Year, time.Now().Year(), a.Owner,
   423  			)
   424  		}
   425  	}
   426  
   427  	if a.License != "" {
   428  		fmtc.Printf("{s-}%s{!}\n", a.License)
   429  	}
   430  
   431  	if a.UpdateChecker.CheckFunc != nil && a.UpdateChecker.Payload != "" {
   432  		newVersion, releaseDate, hasUpdate := a.UpdateChecker.CheckFunc(
   433  			a.App,
   434  			a.Version,
   435  			a.UpdateChecker.Payload,
   436  		)
   437  
   438  		if hasUpdate && isNewerVersion(a.Version, newVersion) {
   439  			printNewVersionInfo(a.Version, newVersion, releaseDate)
   440  		}
   441  	}
   442  
   443  	fmtc.NewLine()
   444  }
   445  
   446  // ////////////////////////////////////////////////////////////////////////////////// //
   447  
   448  // renderCommands renders all supported commands
   449  func renderCommands(info *Info) {
   450  	var curGroup string
   451  
   452  	for _, command := range info.Commands {
   453  		if curGroup != command.Group {
   454  			printGroupHeader(command.Group)
   455  			curGroup = command.Group
   456  		}
   457  
   458  		command.Render()
   459  	}
   460  }
   461  
   462  // renderOptions renders all supported options
   463  func renderOptions(info *Info) {
   464  	printGroupHeader("Options")
   465  
   466  	for _, option := range info.Options {
   467  		option.Render()
   468  	}
   469  }
   470  
   471  // renderExamples renders all usage examples
   472  func renderExamples(info *Info) {
   473  	printGroupHeader("Examples")
   474  
   475  	total := len(info.Examples)
   476  
   477  	for index, example := range info.Examples {
   478  		example.Render()
   479  
   480  		if index < total-1 {
   481  			fmtc.NewLine()
   482  		}
   483  	}
   484  }
   485  
   486  // renderArgs renders args with colors
   487  func renderArgs(args ...string) string {
   488  	var result string
   489  
   490  	for _, a := range args {
   491  		if strings.HasPrefix(a, "?") {
   492  			result += "{s-}" + a[1:] + "{!} "
   493  		} else {
   494  			result += "{s}" + a + "{!} "
   495  		}
   496  	}
   497  
   498  	return fmtc.Sprintf(strings.TrimRight(result, " "))
   499  }
   500  
   501  // formatOptionName formats option name
   502  func formatOptionName(opt *Option) string {
   503  	if opt.Short != "" {
   504  		return "--" + opt.Long + ", -" + opt.Short
   505  	}
   506  
   507  	return "--" + opt.Long
   508  }
   509  
   510  // parseOptionName parses option name
   511  func parseOptionName(name string) (string, string) {
   512  	if strings.Contains(name, ":") {
   513  		return strutil.ReadField(name, 1, false, ":"),
   514  			strutil.ReadField(name, 0, false, ":")
   515  	}
   516  
   517  	return name, ""
   518  }
   519  
   520  // getSeparator return bread crumbs (or spaces if colors are disabled) for
   521  // item name aligning
   522  func getSeparator(size, maxSize int, breadcrumbs bool) string {
   523  	if breadcrumbs && !fmtc.DisableColors && maxSize > _BREADCRUMBS_MIN_SIZE {
   524  		return " {s-}" + _DOTS[:maxSize-size] + "{!} "
   525  	}
   526  
   527  	return " " + _SPACES[:maxSize-size] + " "
   528  }
   529  
   530  // getMaxCommandSize returns the biggest command size
   531  func getMaxCommandSize(commands []*Command) int {
   532  	var size int
   533  
   534  	for _, command := range commands {
   535  		size = mathutil.Max(size, getCommandSize(command)+2)
   536  	}
   537  
   538  	return size
   539  }
   540  
   541  // getMaxOptionSize returns the biggest option size
   542  func getMaxOptionSize(options []*Option) int {
   543  	var size int
   544  
   545  	for _, option := range options {
   546  		size = mathutil.Max(size, getOptionSize(option)+2)
   547  	}
   548  
   549  	return size
   550  }
   551  
   552  // getOptionSize calculate rendered command size
   553  func getCommandSize(cmd *Command) int {
   554  	size := strutil.Len(cmd.Name) + 2
   555  
   556  	for _, arg := range cmd.Args {
   557  		if strings.HasPrefix(arg, "?") {
   558  			size += strutil.Len(arg)
   559  		} else {
   560  			size += strutil.Len(arg) + 1
   561  		}
   562  	}
   563  
   564  	return size
   565  }
   566  
   567  // getOptionSize calculate rendered option size
   568  func getOptionSize(opt *Option) int {
   569  	var size int
   570  
   571  	if opt.Short != "" {
   572  		size += strutil.Len(opt.Long) + strutil.Len(opt.Short) + 4
   573  	} else {
   574  		size += strutil.Len(opt.Long) + 1
   575  	}
   576  
   577  	if opt.Arg != "" {
   578  		size += strutil.Len(opt.Arg)
   579  
   580  		if !strings.HasPrefix(opt.Arg, "?") {
   581  			size++
   582  		}
   583  	}
   584  
   585  	return size
   586  }
   587  
   588  // printGroupHeader print category header
   589  func printGroupHeader(name string) {
   590  	fmtc.Printf("\n{*}%s{!}\n\n", name)
   591  }
   592  
   593  // isNewerVersion return true if latest version is greater than current
   594  func isNewerVersion(current, latest string) bool {
   595  	v1, err := version.Parse(current)
   596  
   597  	if err != nil {
   598  		return false
   599  	}
   600  
   601  	v2, err := version.Parse(latest)
   602  
   603  	if err != nil {
   604  		return false
   605  	}
   606  
   607  	return v2.Greater(v1)
   608  }
   609  
   610  // printNewVersionInfo print info about latest release
   611  func printNewVersionInfo(curVersion, newVersion string, releaseDate time.Time) {
   612  	cv, err := version.Parse(curVersion)
   613  
   614  	if err != nil {
   615  		return
   616  	}
   617  
   618  	nv, err := version.Parse(newVersion)
   619  
   620  	if err != nil {
   621  		return
   622  	}
   623  
   624  	days := int(time.Since(releaseDate) / (time.Hour * 24))
   625  
   626  	colorTag := "{s}"
   627  
   628  	switch {
   629  	case cv.Major() != nv.Major():
   630  		colorTag = "{r}"
   631  	case cv.Minor() != nv.Minor():
   632  		colorTag = "{y}"
   633  	}
   634  
   635  	fmtc.NewLine()
   636  	fmtc.Printf(colorTag+"Latest version is %s{!} ", newVersion)
   637  
   638  	switch days {
   639  	case 0:
   640  		fmtc.Println("{s-}(released today){!}")
   641  	case 1:
   642  		fmtc.Println("{s-}(released 1 day ago){!}")
   643  	default:
   644  		fmtc.Printf("{s-}(released %d days ago){!}\n", days)
   645  	}
   646  }