diff --git a/cmd/alias.go b/cmd/alias.go index 20d7044..a34b62d 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -6,7 +6,6 @@ package cmd import ( "bytes" "embed" - "log" "os" "text/template" @@ -56,7 +55,7 @@ Read the template script 'tome-wrapper.sh.tmpl' for more information on how the t, err := template.ParseFS(content, "embeds/tome-wrapper.sh.tmpl") // Capture any error if err != nil { - log.Fatalln(err) + log.Fatal(err) } buf := new(bytes.Buffer) v, err := cmd.Flags().GetString("output") diff --git a/cmd/docs.go b/cmd/docs.go index 44c08ad..8ebe84e 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -4,8 +4,6 @@ Copyright © 2024 Zander Hill package cmd import ( - "log" - "github.com/spf13/cobra" "github.com/spf13/cobra/doc" ) diff --git a/cmd/lib.go b/cmd/lib.go index 95ea85f..2ec32d3 100644 --- a/cmd/lib.go +++ b/cmd/lib.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/url" "os" "os/exec" diff --git a/cmd/root.go b/cmd/root.go index 123a58c..262469a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/gobeam/stringy" "github.com/spf13/cobra" "github.com/spf13/viper" + "go.uber.org/zap" ) var rootDir string @@ -64,9 +65,11 @@ func init() { viper.SetDefault("license", "mit") } +var log *zap.SugaredLogger + // initConfig reads in config file and ENV variables if set. func initConfig() { - log := createLogger("initConfig", rootCmd.OutOrStderr()) + log = createLogger("initConfig", rootCmd.OutOrStderr()) v := viper.GetViper() var err error rootDir, err = filepath.Abs(rootDir) diff --git a/cmd/struct.go b/cmd/struct.go index 9d5135c..f4006fc 100644 --- a/cmd/struct.go +++ b/cmd/struct.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "fmt" "os" "path/filepath" @@ -48,35 +49,91 @@ func (s *Script) IsExecutable() bool { return isExecutableByOwner(fileInfo.Mode()) } -func (s *Script) parse() error { - b, err := os.ReadFile(s.path) +// ParseV2 returns the usage and help text for the script +// function aims to return early and perform as little work as possible +// to avoid reading the entire file and stay performant +// with large script folders and files +func (s *Script) ParseV2() (string, string, error) { + log.Debugw("Parsing script", "path", s.path) + file, err := os.Open(s.path) if err != nil { - return err + return "", "", err } - - if strings.Contains(string(b), UsageKey) || strings.Contains(string(b), LegacyUsageKey) { - lines := strings.Split(string(b), "\n") - var linesStart int - for idx, line := range lines { - if strings.Contains(line, UsageKey) || strings.Contains(line, LegacyUsageKey) { - linesStart = idx + defer file.Close() + + scanner := bufio.NewScanner(file) + + // Parse the script file for the usage and help text + // Expected structure is: + // #!/bin/bash + // # USAGE: script.sh [options] + // # This is the help text for the script + // # It can span multiple lines + // + // echo 1 + + var usage, help string + idx := 0 + + var helpArr []string + + startsWithComment := regexp.MustCompile(`^[/*\-#]+`) + for scanner.Scan() { + t := scanner.Text() + log.Debugw("Parsing line", "line", t) + // Skip the shebang line + if idx == 0 && strings.HasPrefix(t, "#!") { + log.Debugw("shebang", "line", t) + idx++ + continue + } + // Normally this is the usage line + if idx == 1 { + log.Debugw("likely usage", "line", t) + if !startsWithComment.MatchString(t) { + usage = "" + help = "" break + } else { + withoutCommentChars := strings.TrimLeft(t, "#/-*") + regexes := []regexp.Regexp{ + *regexp.MustCompile(`(USAGE|SUMMARY):`), + *regexp.MustCompile(fmt.Sprintf(`(%s|%s)`, regexp.QuoteMeta(`$0`), regexp.QuoteMeta(filepath.Base(s.path)))), + *regexp.MustCompile(`TOME_[A-Z_]+`), // ignore tome option flags + } + for _, r := range regexes { + withoutCommentChars = r.ReplaceAllLiteralString(withoutCommentChars, "") + } + usage = strings.TrimSpace(withoutCommentChars) + log.Debugw("usage", "usage", usage) + idx++ } } - var helpEnds int - for idx, line := range lines[linesStart:] { - if line == "" { - helpEnds = idx + linesStart - break - } + // Scan until we find an empty line + if startsWithComment.MatchString(t) { + t2 := strings.TrimSpace(strings.TrimLeft(t, "#/-*")) + log.Debugw("help line", "line", t2) + helpArr = append(helpArr, t2) + idx++ + continue + } else { + break } - helpTextLines := lines[linesStart:helpEnds] - helpText := strings.Join(helpTextLines, "\n") + } + help = strings.Join(helpArr, "\n") - s.usage = strings.TrimSpace(strings.SplitN(lines[linesStart], ":", 2)[1]) - s.help = helpText + return usage, help, nil +} + +func (s *Script) parse() error { + usage, help, err := s.ParseV2() + if err != nil { + return err } + s.usage = usage + s.help = help + return nil } @@ -84,24 +141,11 @@ func (s *Script) parse() error { // after stripping out the script name or $0 // this is done to reduce visual noise func (s *Script) Usage() string { - baseUsage := s.usage - prefixes := []string{"$0", filepath.Base(s.path)} - for _, prefix := range prefixes { - baseUsage = strings.TrimPrefix(baseUsage, prefix) - } - baseUsage = strings.TrimSpace(baseUsage) - return dedent.Dedent(baseUsage) + return dedent.Dedent(s.usage) } func (s *Script) Help() string { - lines := strings.Split(s.help, "\n") - var helpTextLines []string - toTrim := []string{"#", "//", "/\\*", "\\*/", "--"} - toTrimRegex := regexp.MustCompile(fmt.Sprintf("^(%s)+", strings.Join(toTrim, "|"))) - for _, line := range lines { - helpTextLines = append(helpTextLines, toTrimRegex.ReplaceAllString(line, "")) - } - return dedent.Dedent(strings.Join(helpTextLines, "\n")) + return dedent.Dedent(s.help) } func (s *Script) PathWithoutRoot() string {